1 Commits

117 changed files with 2073 additions and 6520 deletions

View File

@@ -16,5 +16,3 @@ POSTGRES_DB=vodSystem
FRONTEND_URL= FRONTEND_URL=
BACKEND_URL= BACKEND_URL=
STREAM_RING_LENGTH_SECOND=28800

View File

@@ -1,118 +0,0 @@
name: CI
on:
push:
env:
REGISTRY: gitea.vodsystem.ddns.net
IMAGE_PREFIX: dylan/vod-system
jobs:
unit-tests:
name: Maven unit tests
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: https://github.com/actions/checkout@v4
- name: Set up Java 21
uses: https://github.com/actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
cache: maven
- name: Run unit tests
run: ./mvnw --batch-mode --no-transfer-progress test
build-and-push:
name: Build and push images
needs: unit-tests
if: gitea.ref == 'refs/heads/master'
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: https://github.com/actions/checkout@v4
- name: Set up Docker Buildx
uses: https://github.com/docker/setup-buildx-action@v3
- name: Log in to Gitea container registry
uses: https://github.com/docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push backend image
uses: https://github.com/docker/build-push-action@v6
with:
context: .
file: src/main/Dockerfile
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-backend:latest
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-backend:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-backend:buildcache,mode=max
- name: Build and push frontend image
uses: https://github.com/docker/build-push-action@v6
with:
context: ./frontend
file: frontend/Dockerfile
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-frontend:latest
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-frontend:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-frontend:buildcache,mode=max
- name: Build and push rtmp image
uses: https://github.com/docker/build-push-action@v6
with:
context: ./nginx
file: nginx/Dockerfile
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-rtmp:latest
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-rtmp:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-rtmp:buildcache,mode=max
deploy:
name: Deploy to server
needs: build-and-push
if: gitea.ref == 'refs/heads/master'
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: https://github.com/actions/checkout@v4
- name: Copy production compose file to server
uses: https://github.com/appleboy/scp-action@v0.1.7
with:
host: ${{ vars.DEPLOY_HOST }}
username: ${{ vars.DEPLOY_USER }}
port: 22
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: docker-compose.prod.yml
target: ${{ vars.DEPLOY_PATH }}
strip_components: 0
- name: Pull latest images and restart containers
uses: https://github.com/appleboy/ssh-action@v1.0.3
with:
host: ${{ vars.DEPLOY_HOST }}
username: ${{ vars.DEPLOY_USER }}
port: 22
key: ${{ secrets.DEPLOY_SSH_KEY }}
envs: REGISTRY,REGISTRY_USER,REGISTRY_TOKEN,DEPLOY_PATH
script: |
set -e
echo "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
cd "$DEPLOY_PATH"
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d --remove-orphans
env:
REGISTRY: ${{ env.REGISTRY }}
REGISTRY_USER: ${{ gitea.actor }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
DEPLOY_PATH: ${{ vars.DEPLOY_PATH }}

1
.gitignore vendored
View File

@@ -44,3 +44,4 @@ build/
frontend/.flowbite-react frontend/.flowbite-react
generated generated
.mvn

View File

@@ -1,3 +0,0 @@
wrapperVersion=3.3.4
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

144
README.md
View File

@@ -1,121 +1,33 @@
# VoD System # Overview
The VoD System is a powerful clip management platform designed to streamline how you handle your video content. Whether you're a content creator, streamer, or educator, VoD System lets you:
A self-hosted clip management and live streaming platform. Upload videos, trim and compress clips, save sections of a live stream, and organise everything through a clean web interface — all running locally under Docker. - Upload clips effortlessly and securely.
- Edit and trim videos with intuitive controls.
- Compress files to specific file sizes.
- Organize your clips for quick access and sharing.
This project started in secondary school during lockdown as a Python script to squeeze game clips under Discord's 8 MB file size limit. It has since grown into a full-stack application with a Spring Boot backend, a React/Vite/TypeScript frontend, and an nginx-rtmp sidecar for live streaming. This VoD system began as a small project back in my 5th year of secondary school, during lockdown. I wanted a quick way to send game clips to Discord, but the 8MB file size limit made it frustrating to manually tweak bitrates for every clip. To solve that, I wrote a simple Python script to automate the process. What started as a quick fix has since evolved into a full-fledged system — now built with a Spring Boot backend and a modern React/Vite frontend.
---
## Tech Stack
| Layer | Technology |
|------------|---------------------------------------------------------|
| Backend | Java 21, Spring Boot 3.4, Spring Security, JPA, FFmpeg |
| Frontend | React 19, TypeScript, Vite, Tailwind CSS v4 |
| Database | PostgreSQL 15 |
| Streaming | nginx-rtmp |
| Auth | Google OAuth 2.0 + JWT |
| Runtime | Docker Compose |
---
## Requirements ## Requirements
- Docker & Docker Compose
- JDK 21+
- Node.js 16+
- FFMPEG
- Google Client keys
## Steps
1. Create your own two `.env` files based off the `example.env` files in the root and in the frontend folder
2. Spin up docker container with `docker compose up`
3. Run `./mvnw clean package` to build the jar
4. Run `./mvnw spring-boot:run` to run the backend section
5. Run `cd frontend && npm install && npm run dev` to build and run the frontend
6. Endpoints should be available at 8080 (backend) and 5173 (frontend)
- Docker and Docker Compose # Future Plans
- A Google Cloud project with OAuth 2.0 credentials (Client ID and Secret) - **Format Handling**
- Backend conversion of non-MP4 files via FFMPEG for broader format support
That is all. The backend, frontend, database, and RTMP server all run as containers — no local JDK, Node, or FFmpeg install required. - **Input Sources**
- Support for local file uploads and YouTube imports
--- - **Testing**
- Unit tests, including coverage for FFMPEG-related functionality
## Getting Started - **API Documentation**
- Comprehensive and maintainable backend API docs
### 1. Configure the environment
Copy the example file and fill in the values:
```bash
cp .env.example .env
```
Open `.env` and set the following:
```dotenv
# OAuth credentials from your Google Cloud Console project
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# A long random string used to sign JWTs — treat it like a password
JWT_SECRET_KEY=
# Absolute paths on the host where persistent data will be stored
POSTGRES_DATA=/path/to/postgres/data
STREAM_DATA=/path/to/stream/data
MEDIA_DATA=/path/to/media/data
# Database credentials — change these from the defaults before deploying
POSTGRES_URL=jdbc:postgresql://postgres:5432/vodSystem
POSTGRES_USER=myuser
POSTGRES_PASSWORD=mypassword
POSTGRES_DB=vodSystem
# The URLs the frontend will use to reach itself and the backend
FRONTEND_URL=http://localhost:5173
BACKEND_URL=http://localhost:8080
```
### 2. Start the application
```bash
docker compose up --build
```
This builds and starts four containers: `vod_postgres`, `vod_backend`, `vod_frontend`, and `vod_rtmp`.
Once everything has started, the interfaces are available at:
- Frontend: [http://localhost:5173](http://localhost:5173)
- Backend API: [http://localhost:8080/api/v1](http://localhost:8080/api/v1)
- RTMP ingest: `rtmp://localhost:1935/live`
### 3. Authenticate
Log in through the frontend using your Google account. The backend exchanges the Google ID token for a short-lived JWT that is used on all subsequent API requests.
---
## Development Mode
For local backend or frontend development, a separate Compose file is provided that starts only the infrastructure services (PostgreSQL and the RTMP server), leaving the backend and frontend to run directly on the host:
```bash
docker compose -f docker-compose.dev.yml up
```
Then run the backend and frontend separately in your usual workflow.
- **Important**: Rootless docker should be used to avoid permission conflicts between the RTMP container and the Spring Boot host
---
## API Reference
A [Bruno](https://www.usebruno.com/) collection is included under `bruno/VoD-System/` with pre-configured requests covering all major endpoints. Import it directly into Bruno and point the `local` environment at `http://localhost:8080/api/v1`.
The collection is organised into four folders:
| Folder | Description |
|----------|--------------------------------------------------|
| Users | Google OAuth login flow, current user |
| Media | Upload, compress, clip stream, save section |
| Jobs | Poll job status, download completed output |
| Streams | Stream history |
---
## Future Plans
- **Format handling** — backend conversion of non-MP4 files via FFmpeg for broader input format support
- **Input sources** — YouTube imports and additional local upload options
- **Testing** — unit and integration test coverage, including FFmpeg-related functionality
- **API documentation** — comprehensive, auto-generated backend API docs

View File

@@ -1,22 +0,0 @@
meta {
name: Create Marker
type: http
seq: 3
}
post {
url: {{base_url}}/markers
body: json
auth: inherit
}
body:json {
{
"message": "Nice play"
}
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -1,16 +0,0 @@
meta {
name: Delete Marker
type: http
seq: 4
}
delete {
url: {{base_url}}/markers/5
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -1,16 +0,0 @@
meta {
name: Get Marker
type: http
seq: 1
}
get {
url: {{base_url}}/markers/5
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -1,11 +0,0 @@
meta {
name: Get all markers
type: http
seq: 2
}
get {
url: {{base_url}}/markers
body: none
auth: inherit
}

View File

@@ -1,8 +0,0 @@
meta {
name: Markers
seq: 6
}
auth {
mode: inherit
}

View File

@@ -12,9 +12,7 @@ post {
body:json { body:json {
{ {
"duration": 60.0, "duration": 60.0
"title": "Hello",
"description": "my clip"
} }
} }

View File

@@ -1,24 +0,0 @@
meta {
name: Save Stream Section By Markers
type: http
seq: 4
}
post {
url: {{base_url}}/media/save/markers
body: json
auth: inherit
}
body:json {
{
"startMarkerId": 1,
"endMarkerId": 2,
"title": "Hello",
"description": "my clip"
}
}
script:post-response {
bru.setEnvVar("uuid", res.getBody().data.uuid);
}

View File

@@ -12,10 +12,8 @@ post {
body:json { body:json {
{ {
"startTime": "2026-07-01T21:09:00Z", "startTime": "2026-06-21T17:27:00Z",
"endTime": "2026-07-01T21:12:00Z", "endTime": "2026-06-21T17:28:00Z"
"title": "Hello",
"description": "my clip"
} }
} }

View File

@@ -1,16 +0,0 @@
meta {
name: Delete VoD
type: http
seq: 4
}
delete {
url: {{base_url}}/vods/1
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -1,16 +0,0 @@
meta {
name: Get VoD Media
type: http
seq: 1
}
get {
url: {{base_url}}/vods/1/media
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -1,15 +0,0 @@
meta {
name: Get VoD Thumbnail
type: http
seq: 2
}
get {
url: {{base_url}}/vods/1/thumbnail
body: none
auth: inherit
}
settings {
encodeUrl: true
}

View File

@@ -1,8 +0,0 @@
meta {
name: Downloads
seq: 5
}
auth {
mode: inherit
}

View File

@@ -1,16 +0,0 @@
meta {
name: Get VoD
type: http
seq: 2
}
get {
url: {{base_url}}/vods/1
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -1,11 +0,0 @@
meta {
name: Get all vods
type: http
seq: 1
}
get {
url: {{base_url}}/vods
body: none
auth: inherit
}

View File

@@ -1,23 +0,0 @@
meta {
name: Update VoD
type: http
seq: 3
}
patch {
url: {{base_url}}/vods/1
body: json
auth: inherit
}
body:json {
{
"title": "My VoD",
"description": ""
}
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -1,8 +0,0 @@
meta {
name: VoDs
seq: 2
}
auth {
mode: inherit
}

View File

@@ -1,8 +0,0 @@
vars {
base_url: http://http://server.tail3cd897.ts.net:8087/api/v1
uuid:
}
vars:secret [
google_client_id,
google_secret
]

View File

@@ -22,8 +22,6 @@ services:
container_name: vod_rtmp container_name: vod_rtmp
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
env_file:
- .env
environment: environment:
- WEB_SERVER_URL=http://host.docker.internal:8080 - WEB_SERVER_URL=http://host.docker.internal:8080
ports: ports:

View File

@@ -1,57 +0,0 @@
# Production deploy: uses pre-built images from the Gitea container registry
# instead of building locally. Populated by CI onto the server alongside .env.
# Usage: docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d
services:
postgres:
image: postgres:15
container_name: vod_postgres
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- ${POSTGRES_DATA}:/var/lib/postgresql/data
restart: unless-stopped
backend:
image: gitea.vodsystem.ddns.net/dylan/vod-system-backend:latest
container_name: vod_backend
depends_on:
- postgres
env_file:
- .env
volumes:
- ${MEDIA_DATA}:/app/media
- ${STREAM_DATA}:/app/stream
ports:
- "8089:8080"
restart: unless-stopped
frontend:
image: gitea.vodsystem.ddns.net/dylan/vod-system-frontend:latest
container_name: vod_frontend
environment:
VITE_API_URL: ${BACKEND_URL}
VITE_FRONTEND_URL: ${FRONTEND_URL}
VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
depends_on:
- backend
ports:
- "5173:5173"
restart: unless-stopped
rtmp:
image: gitea.vodsystem.ddns.net/dylan/vod-system-rtmp:latest
container_name: vod_rtmp
env_file:
- .env
environment:
- WEB_SERVER_URL=http://backend:8080
ports:
- "1935:1935"
volumes:
- ${STREAM_DATA}:/stream
restart: unless-stopped
depends_on:
- backend

View File

@@ -32,7 +32,6 @@ services:
container_name: vod_frontend container_name: vod_frontend
environment: environment:
VITE_API_URL: ${BACKEND_URL} VITE_API_URL: ${BACKEND_URL}
VITE_FRONTEND_URL: ${FRONTEND_URL}
VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
depends_on: depends_on:
- backend - backend
@@ -45,8 +44,6 @@ services:
context: ./nginx context: ./nginx
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: vod_rtmp container_name: vod_rtmp
env_file:
- .env
environment: environment:
- WEB_SERVER_URL=http://backend:8080 - WEB_SERVER_URL=http://backend:8080
ports: ports:

138
ethos.md
View File

@@ -1,138 +0,0 @@
# The VoD System — Frontend Design Reference
Warm, editorial, low-key. Soft shadows instead of borders, cream surfaces, one terracotta and one olive accent. No dark-dashboard chrome.
---
## 1. Typography
| Role | Family | Usage |
| --- | --- | --- |
| Display / headings | **Newsreader** (serif, weight 400) | Page titles, card headings, item titles, greeting, logo wordmark. Always regular weight — never bold. |
| UI / body | **IBM Plex Sans** (400, 500) | Nav, labels, buttons, body copy, table cells. 500 only for the active nav item. |
| Data / mono | **IBM Plex Mono** (400, 500) | Timecodes, stream key, ingest URL, durations, bitrates, file facts, kickers, job stages. |
Fallbacks: `system-ui, sans-serif` for Plex Sans; `serif` for Newsreader; `monospace` for Plex Mono.
### Type scale
| Element | Size | Family | Notes |
| --- | --- | --- | --- |
| Greeting (dashboard h1) | 40px | Newsreader | `letter-spacing: -0.015em` |
| Page h1 | 36px | Newsreader | -0.015em |
| Detail title | 31px | Newsreader | -0.015em |
| Drop-zone headline | 25px | Newsreader | |
| Section h2 | 23px | Newsreader | |
| Logo wordmark | 22px | Newsreader | -0.01em |
| Card heading (in-panel) | 20–22px | Newsreader | |
| Status headline | 27px | Newsreader | line-height 1.25 |
| VOD tile title | 17.5–18px | Newsreader | line-height 1.3 |
| Big metric | 21px | Plex Mono | elapsed, marker count, bitrate |
| Range length | 15px | Plex Mono | |
| Body paragraph | 14.5px | Plex Sans | line-height 1.65, `text-wrap: pretty`, max 62ch |
| Primary button | 13.5–14px | Plex Sans | |
| Field value | 13.5–14px | Plex Sans / Mono | mono for timecodes and sizes |
| Table cell | 13.5px | Plex Sans | mono for numeric columns |
| Clip tile title | 13.5px | Plex Sans | |
| Meta line | 12–13px | Plex Sans | muted ink |
| Field label | 11.5px | Plex Sans | muted ink |
| Kicker / eyebrow | 10.5–11px | Plex Mono | `letter-spacing: 0.10–0.14em`, uppercase |
| Ruler / tick caption | 10–10.5px | Plex Mono | |
---
## 2. Colour
### Surfaces
| Token | Hex | Where |
| --- | --- | --- |
| Canvas | `#F7F4EE` | Page background |
| Sidebar | `#F2EEE6` | Nav column and collapsed rail |
| Card | `#FDFBF7` | Cards, library tiles, active nav pill |
| Field well | `#F4F0E8` | Inputs, credential wells, timeline track |
| Muted panel | `#F1ECE3` / `#F1EDE4` | Secondary buttons, "how it works", clip-cutter panel |
| Drop zone (idle) | `#F9F6EF` | Upload empty state |
| Armed / selected | `#F6EFE6` | Selected marker row, filled range card |
| Live card | `#F0EBE1` | Dashboard status block when live |
| Player body | `#2B2620` | Video chrome, also the dark button |
| Player stripes | `#3A342C` / `#332E27` | Placeholder frame texture |
### Ink
| Token | Hex | Where |
| --- | --- | --- |
| Primary | `#2B2620` | Headings, metrics |
| Strong | `#35302A` | Field values, tile titles |
| Body | `#554D44` | Paragraph copy |
| Control label | `#4A423A` | Secondary button text |
| Secondary | `#6E6559` | Supporting copy |
| Table value | `#7A7166` | History rows |
| Muted | `#9A9084` | Labels, meta, kickers |
| Faint | `#A79C8E` | Mono captions, ruler ticks, hints |
| On dark | `#FBF8F2` | Text on player / dark button |
| On accent | `#FDFBF7` | Text on terracotta / olive |
### Accents
| Token | Hex | Where |
| --- | --- | --- |
| Terracotta | `#A85A38` | Logo mark, primary CTA, links, marker timecodes, playheads |
| Terracotta hover | `#94492A` | Primary button hover |
| Terracotta light | `#C87A52` | Progress bars, scrubber fill, selected marker ring |
| Terracotta disabled | `#C9AE9C` | "Waiting for a file" button |
| Olive | `#6E7A50` | Clip actions, trim in/out handles |
| Olive hover | `#5D6843` | |
| Olive light | `#8A9668` | Completed job bar |
| Live red | `#B5432E` | Live dot and "LIVE NOW" label |
### Lines, chips, placeholders
| Token | Hex | Where |
| --- | --- | --- |
| Hairline | `#F0EBE2` | Row dividers (1px) |
| Chip fill | `#EAE3D7`, `#EFE9DE`, `#E4DDD0` | Progress track, toggles, nav-hide button |
| Chip hover | `#E7E0D2`, `#DAD1C0`, `#EAE4D9` | |
| Drop-zone ring | `#E2DACB` | `inset 0 0 0 1.5px` |
| Inactive marks | `#DED7CA`, `#C4BAA9` | Unchecked boxes, unselected ticks |
| Zoom window outline | `#8E8577` | Overview strip frame |
| VOD thumbnail | `#E3DACB` / `#DAD0BF` | 135° stripes, 9px |
| Clip thumbnail | `#DFE0D2` / `#D6D8C7` | 135° stripes, 9px |
| Avatar | `#D9C9AE` on `#5C4A32` | Google account chip |
---
## 3. Elevation & shape
- **Card**: `0 1px 2px rgba(60,48,32,.05), 0 10px 26px -18px rgba(60,48,32,.35)`
- **Card hover**: `0 2px 4px rgba(60,48,32,.07), 0 18px 34px -18px rgba(60,48,32,.45)`
- **Panel (heavier)**: `0 1px 2px rgba(60,48,32,.05), 0 16px 36px -22px rgba(60,48,32,.3)`
- **Player**: `0 1px 2px rgba(60,48,32,.08), 0 22px 46px -26px rgba(60,48,32,.5)`
- **Terracotta button**: `0 1px 2px rgba(90,40,20,.2), 0 12px 22px -14px rgba(90,40,20,.55)`
- **Olive button**: `0 1px 2px rgba(50,60,30,.18), 0 10px 20px -14px rgba(50,60,30,.55)`
- **Inset ring** (used instead of borders): `inset 0 0 0 1px rgba(60,48,32,.08)`
Radii: 16px hero/status blocks · 15px panels · 13px tiles · 12px thumbnails/player · 10px inputs & dark buttons · 9px buttons, nav pills, logo mark · 8px chips and fields · 5–6px overlay badges · 50% avatars and play button.
Media ratios: 16/9 VOD thumbnails and player · 4/3 clip thumbnails · 16/7 upload drop zone.
---
## 4. Layout
- Shell: `252px` sidebar + fluid main; collapsed rail is `62px`.
- Main padding `40px 52px 72px`, content max-width `1060–1180px`.
- Library grids: 3 columns for VODs (gap 22px), 4 for clips (gap 18–20px).
- Detail and stream pages: content column + `330–340px` sticky side panel, gap 26px.
- Spacing rhythm: 26–34px between sections, 14–20px inside cards, 6–10px label-to-field. Always flex/grid with `gap`.
---
## 5. Motion & state
- `livePulse` — 2.2s ease-in-out infinite opacity/scale pulse, live dot only. Nothing else animates decoratively.
- Hover: background shifts one step darker within the same family; cards deepen their shadow. No transforms.
- Selection: `#F6EFE6` fill plus a `1.5px` terracotta inset ring.
- Progress is always a bar with a stage word and remaining time (`queued → reading source → encoding → writing file → complete`) — never a spinner.
---
## 6. Copy tone
Lowercase mono for machine facts, sentence case for everything else. Personal and specific ("Cat walks across the patch bay"), never marketing voice. Durations as `h:mm:ss`, timecodes as `hh:mm:ss`, sizes in MB/GB.

View File

@@ -2,7 +2,7 @@ FROM node:20-alpine
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci RUN npm ci
COPY . . COPY . .
EXPOSE 5173 EXPOSE 5173

View File

@@ -2,14 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/small_icon.png" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Newsreader:wght@100;200;300;400;500;600;700&family=IBM+Plex+Sans:wght@100;200;300;400;500;600;700&family=IBM+Plex+Mono:wght@100;200;300;400;500;600;700&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>The VoD System</title> <title>Vite + React + TS</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@
}, },
"dependencies": { "dependencies": {
"@react-oauth/google": "^0.12.2", "@react-oauth/google": "^0.12.2",
"@tailwindcss/vite": "^4.1.7",
"axios": "^1.11.0", "axios": "^1.11.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
@@ -23,12 +24,11 @@
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-range-slider-input": "^3.2.1", "react-range-slider-input": "^3.2.1",
"react-router-dom": "^7.8.0" "react-router-dom": "^7.8.0",
"tailwindcss": "^4.1.7"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.25.0", "@eslint/js": "^9.25.0",
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^26.1.1",
"@types/react": "^19.1.2", "@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2", "@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1", "@vitejs/plugin-react": "^4.4.1",
@@ -36,7 +36,6 @@
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19", "eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0", "globals": "^16.0.0",
"tailwindcss": "^4.3.3",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"typescript-eslint": "^8.30.1", "typescript-eslint": "^8.30.1",
"vite": "^6.3.5", "vite": "^6.3.5",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

42
frontend/src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@@ -6,14 +6,9 @@ import ClipEdit from './pages/ClipEdit';
import Home from './pages/Home'; import Home from './pages/Home';
import {useEffect} from "react"; import {useEffect} from "react";
import MyClips from './pages/MyClips'; import MyClips from './pages/MyClips';
import MyVods from './pages/MyVods'; import VideoPlayer from "./pages/VideoPlayer.tsx";
import MediaPlayer from "./pages/MediaPlayer.tsx";
import { getVodById, getVodMediaUrl } from "./utils/api/vods.ts";
import { getClipById, getClipMediaUrl } from "./utils/api/clips.ts";
import EditClip from "./pages/EditClip.tsx"; import EditClip from "./pages/EditClip.tsx";
import EditVod from "./pages/EditVod.tsx";
import Profile from "./pages/Profile.tsx"; import Profile from "./pages/Profile.tsx";
import StreamControl from "./pages/StreamControl.tsx";
import { GoogleOAuthProvider } from '@react-oauth/google'; import { GoogleOAuthProvider } from '@react-oauth/google';
@@ -31,13 +26,9 @@ function App() {
<Route path="/create" element={<ClipUpload />} /> <Route path="/create" element={<ClipUpload />} />
<Route path="/create/:id" element={<ClipEdit />} /> <Route path="/create/:id" element={<ClipEdit />} />
<Route path="/my-clips" element={<MyClips />} /> <Route path="/my-clips" element={<MyClips />} />
<Route path="/my-vods" element={<MyVods />} /> <Route path="/video/:id" element={<VideoPlayer />} />
<Route path="/video/:id" element={<MediaPlayer noun="Clip" getMediaUrl={getClipMediaUrl} fetchDetails={getClipById} />} />
<Route path="/vod/:id" element={<MediaPlayer noun="VoD" getMediaUrl={getVodMediaUrl} fetchDetails={getVodById} />} />
<Route path="/clips/:id/edit" element={<EditClip />} /> <Route path="/clips/:id/edit" element={<EditClip />} />
<Route path="/vods/:id/edit" element={<EditVod />} />
<Route path="/profile" element={<Profile />} /> <Route path="/profile" element={<Profile />} />
<Route path="/stream" element={<StreamControl />} />
</Route> </Route>
</Routes> </Routes>
</Router> </Router>

View File

@@ -0,0 +1,14 @@
import clsx from "clsx";
type Props = {
children: React.ReactNode;
className?: string;
};
const Box = ({ children, className }: Props) => (
<div className={clsx("bg-white border border-gray-200 shadow-sm rounded-lg", className)}>
{children}
</div>
);
export default Box;

View File

@@ -1,14 +0,0 @@
import clsx from "clsx";
type Props = {
children: React.ReactNode;
className?: string;
};
const Card = ({ children, className }: Props) => (
<div className={clsx("bg-card shadow-md rounded-lg", className)}>
{children}
</div>
);
export default Card;

View File

@@ -1,17 +0,0 @@
import {Lock} from "lucide-react";
const NoAccessPage = () => {
return (
<div className="w-full min-h-screen flex flex-col items-center justify-center gap-3 text-center px-6">
<Lock className="w-8 h-8 text-faint-mono/60 mb-1" strokeWidth={1.5} />
<h2 className="font-heading text-2xl md:text-3xl text-faint-mono">
Log in to see this page
</h2>
<p className="text-sm text-faint-mono/70 max-w-xs">
Sign in with Google to view your clips and account details.
</p>
</div>
);
}
export default NoAccessPage;

View File

@@ -1,106 +1,48 @@
import clsx from "clsx"; import clsx from "clsx";
import SidebarButton from "./buttons/SidebarButton.tsx"; import SidebarButton from "./buttons/SidebarButton.tsx";
import { Plus, Film, Home, Video, Radio } from 'lucide-react'; import { Plus, Film, Home, User as UserIcon } from 'lucide-react';
import {useAuth} from "../utils/AuthContext.tsx"; import type {User} from "../utils/types.ts";
import {type CredentialResponse, GoogleLogin} from "@react-oauth/google";
import {getUser, login, logout} from "../utils/api/users.ts";
type props = { type props = {
user: User | null;
className?: string className?: string
} }
const Divider = () => <hr className="w-full my-1.5 mx-2 border-hover" />; const Divider = () => <hr className="my-1.5 mx-2 border-gray-100" />;
const Sidebar = ({ className }: props) => {
const { user, setUser } = useAuth()
const handleLogin = (response: CredentialResponse) => {
if (!response.credential) {
console.error("No credential received from Google login.");
return;
}
login(response.credential)
.then(() => {
getUser().then(userData => setUser(userData))
})
.catch((error) => {
console.error("Login failed:", error);
});
}
const handleLogout = () => {
logout()
.then(() =>
setUser(null)
)
.catch((error) => {
console.error("Logout failed:", error);
});
}
const Sidebar = ({user, className}: props) => {
return ( return (
<aside className={clsx("h-screen flex flex-col bg-sidebar border-r border-hairline p-2 items-center gap-1", className)}> <aside className={clsx("h-screen flex flex-col bg-white border-r border-gray-200", className)}>
<h2 className={"font-heading text-3xl"}>The VoD System</h2> <div className="px-4 pt-5 pb-3">
<img
<nav className="flex flex-col flex-1 items-center"> className="w-36 mx-auto"
<Divider /> src="../../public/logo.png"
<div className="flex flex-col gap-0.5 w-full"> alt="VoD System Logo"
<SidebarButton url="/" logo={<Home size={18}/>} label="Home" /> />
<SidebarButton url="/create" logo={<Plus size={18}/>} label="Upload" /> </div>
<nav className="flex flex-col flex-1 px-2 pb-4">
<div className="flex flex-col gap-0.5">
<SidebarButton url="/" logo={<Home size={18}/>} label="Home" />
<SidebarButton url="/create" logo={<Plus size={18}/>} label="Create Clip" />
</div> </div>
<Divider />
{user && ( {user && (
<div className="flex flex-col gap-0.5 w-full"> <>
<div> <Divider />
<SidebarButton url="/stream" logo={<Radio size={18}/>} label="Stream" /> <div className="flex flex-col gap-0.5">
<SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" /> <SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" />
<SidebarButton url="/my-vods" logo={<Video size={18}/>} label="VoDs" />
</div>
</div> </div>
</>
)} )}
<div className="flex-1" /> <div className="flex-1" />
<div className={"p-2"}> {user && (
{user ? ( <>
<div>
<Divider /> <Divider />
<div className={"flex gap-2 items-center pt-1"}> <SidebarButton url="/profile" logo={<UserIcon size={18}/>} label="Profile" />
<img </>
className={"w-8 h-8 rounded-full inline-block"}
src={user.profilePictureUrl}
referrerPolicy="no-referrer"
alt={"Profile Picture"}
/>
<label className={"font-body text-text-strong text-sm"}>
{user.name}
</label>
<button
className={"font-body text-muted text-xs hover:bg-hover p-1 pt-1.5 rounded"}
onClick={handleLogout}
>
Sign Out
</button>
</div>
</div>
) : (
<div>
<Divider />
<GoogleLogin
shape={"pill"}
useOneTap={false}
onSuccess={
(credentialResponse) => handleLogin(credentialResponse
)} )}
/>
</div>
)}
</div>
</nav> </nav>
</aside> </aside>
); );

View File

@@ -0,0 +1,103 @@
import { Menu, X } from 'lucide-react';
import { login, logout } from "../utils/endpoints.ts";
import { Dropdown, DropdownItem } from "./Dropdown.tsx";
import { GoogleLogin } from '@react-oauth/google';
import type { User } from "../utils/types.ts";
import type { CredentialResponse } from '@react-oauth/google';
import MenuButton from "./buttons/MenuButton.tsx";
import clsx from "clsx";
import {useNavigate} from "react-router-dom";
type props = {
sidebarToggled: boolean;
setSidebarToggled: (toggled: boolean) => void;
user: User | null;
fetchUser: () => void;
isStreaming: boolean;
className?: string;
}
const Topbar = ({
sidebarToggled,
setSidebarToggled,
user,
fetchUser,
isStreaming,
className}: props) => {
const navigate = useNavigate();
const handleLogin = (response: CredentialResponse) => {
if (!response.credential) {
console.error("No credential received from Google login.");
return;
}
login(response.credential)
.then(() => {
fetchUser();
navigate(0);
})
.catch((error) => {
console.error("Login failed:", error);
});
}
const handleLogout = () => {
logout()
.then(() => {
fetchUser();
navigate("/");
})
.catch((error) => {
console.error("Logout failed:", error);
});
}
return (
<div className={clsx(className, "flex justify-between items-center px-4 py-2 bg-white border-b border-gray-200")}>
<MenuButton onClick={() => setSidebarToggled(!sidebarToggled)}>
{sidebarToggled ? <Menu size={24}/> : <X size={24}/>}
</MenuButton>
{ user ? (
<div>
<img
className={"w-8 h-8 rounded-full inline-block"}
src={user.profilePictureUrl}
referrerPolicy="no-referrer"
/>
<Dropdown label={
<span className="flex items-center gap-1.5">
{user.name}
{isStreaming && (
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-500 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500" />
</span>
)}
</span>
}>
<DropdownItem item="Profile"
onClick={() => navigate("/profile")} />
<DropdownItem item="Logout"
onClick={() => handleLogout()}
className={"text-red-500 font-medium"} />
</Dropdown>
</div>
) :
(
<GoogleLogin
shape={"pill"}
useOneTap={false}
onSuccess={(credentialResponse) => handleLogin(credentialResponse)} />
)}
</div>
)
}
export default Topbar;

View File

@@ -0,0 +1,15 @@
import React from "react";
import clsx from "clsx";
type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;
const MenuButton: React.FC<Props> = ({ className = "", ...props }) => {
return (
<button
className={clsx("p-2 rounded-md text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors duration-150", className)}
{...props}
/>
);
};
export default MenuButton;

View File

@@ -1,5 +1,5 @@
import MenuButton from "./MenuButton.tsx";
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import React from "react";
type Props = { type Props = {
url: string; url: string;
@@ -13,15 +13,9 @@ const SidebarButton = ({url, logo, label}: Props) => {
return ( return (
<Link className="w-full" to={url}> <Link className="w-full" to={url}>
<button className={`flex items-center gap-2.5 w-full text-sm <MenuButton className={`flex items-center gap-2.5 w-full px-3 py-2 text-sm font-medium rounded-md ${isActive ? "bg-gray-100 text-gray-900" : "text-gray-600"}`}>
font-body p-2 hover:bg-hover hover:text-text-strong
rounded-md
${isActive ?
"bg-card text-text-strong font-medium shadow-sm"
:
"text-text-primary"}`}>
{logo}{label} {logo}{label}
</button> </MenuButton>
</Link> </Link>
) )
} }

View File

@@ -5,32 +5,15 @@ type props = {
dataSend: React.MouseEventHandler<HTMLButtonElement>; dataSend: React.MouseEventHandler<HTMLButtonElement>;
handleDownload: React.MouseEventHandler<HTMLButtonElement>; handleDownload: React.MouseEventHandler<HTMLButtonElement>;
downloadable: boolean; downloadable: boolean;
progress: number; progress: number
uploading: boolean;
} }
const Spinner = () => ( const ExportWidget = ({dataSend, handleDownload, downloadable, progress}: props) => {
<svg
className="animate-spin h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
);
const ExportWidget = ({dataSend, handleDownload, downloadable, progress, uploading}: props) => {
return ( return (
<div className={"flex flex-col gap-3"}> <div className={"flex flex-col gap-3"}>
<BlueButton <BlueButton
onClick={dataSend} onClick={dataSend}>
disabled={uploading} Export
className={uploading ? "opacity-60 cursor-not-allowed flex items-center justify-center gap-2" : ""}
>
{uploading && <Spinner />}
{uploading ? "Uploading..." : "Export"}
</BlueButton> </BlueButton>
<div className="h-10 flex items-center"> <div className="h-10 flex items-center">

View File

@@ -2,7 +2,7 @@ import clsx from "clsx";
import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts"; import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { isThumbnailAvailable } from "../../utils/api/client.ts"; import { isThumbnailAvailable } from "../../utils/endpoints.ts";
import { MoreVertical, Pencil, Trash2 } from "lucide-react"; import { MoreVertical, Pencil, Trash2 } from "lucide-react";
type VideoCardProps = { type VideoCardProps = {
@@ -13,21 +13,17 @@ type VideoCardProps = {
onEdit?: () => void, onEdit?: () => void,
onDelete?: () => void, onDelete?: () => void,
className?: string, className?: string,
mediaApiPath?: string,
playerPath?: string,
itemLabel?: string,
} }
const fallbackThumbnail = "../../../public/default_thumbnail.png"; const fallbackThumbnail = "../../../public/default_thumbnail.png";
const API_URL = import.meta.env.VITE_API_URL; const API_URL = import.meta.env.VITE_API_URL;
const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className, mediaApiPath = '/api/v1/clips', playerPath = '/video', itemLabel = 'clip' }: VideoCardProps) => { const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className }: VideoCardProps) => {
const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt))); const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt)));
const [thumbnailAvailable, setThumbnailAvailable] = useState(true); const [thumbnailAvailable, setThumbnailAvailable] = useState(true);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const thumbnailUrl = `${API_URL}${mediaApiPath}/${id}/thumbnail`;
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
@@ -37,10 +33,10 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
}, [createdAt]); }, [createdAt]);
useEffect(() => { useEffect(() => {
isThumbnailAvailable(thumbnailUrl) isThumbnailAvailable(id)
.then(setThumbnailAvailable) .then(setThumbnailAvailable)
.catch(() => setThumbnailAvailable(false)); .catch(() => setThumbnailAvailable(false));
}, [thumbnailUrl]); }, []);
useEffect(() => { useEffect(() => {
if (!menuOpen) return; if (!menuOpen) return;
@@ -61,11 +57,11 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
}; };
return ( return (
<Link to={`${playerPath}/${id}`}> <Link to={"/video/" + id}>
<div className={clsx("flex flex-col group cursor-pointer", className)}> <div className={clsx("flex flex-col group cursor-pointer", className)}>
<div className="relative overflow-hidden rounded-lg"> <div className="relative overflow-hidden rounded-lg">
<img <img
src={thumbnailAvailable ? thumbnailUrl : fallbackThumbnail} src={thumbnailAvailable ? API_URL + `/api/v1/clips/${id}/thumbnail` : fallbackThumbnail}
alt="Video Thumbnail" alt="Video Thumbnail"
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200" className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200"
/> />
@@ -109,7 +105,7 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
</>)} </>)}
{confirmDelete && ( {confirmDelete && (
<div className="px-3 py-2"> <div className="px-3 py-2">
<p className="text-xs text-gray-600 mb-2">Delete this {itemLabel}?</p> <p className="text-xs text-gray-600 mb-2">Delete this clip?</p>
<div className="flex gap-1.5"> <div className="flex gap-1.5">
<button <button
onClick={(e) => stopAndRun(e, onDelete!)} onClick={(e) => stopAndRun(e, onDelete!)}

View File

@@ -1,27 +1,33 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "flowbite-react/plugin/tailwindcss"; @plugin "flowbite-react/plugin/tailwindcss";
@source "../.flowbite-react/class-list.json";
@theme { @theme {
/* Fonts */ --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
/*--font-heading: "Newsreader", serif; */
--font-body: "IBM Plex Sans", sans-serif;
--font-data: "IBM Plex Mono", monospace;
/* Colours */ /* Breakpoints */
--color-background: #F7F4EE; --breakpoint-3xl: 1920px;
--color-sidebar: #F2EEE6;
--color-card: #FDFBF7;
--color-accent: #A85A38;
--color-accent-hover: #94492A;
--color-live-card: #F0EBE1;
/* Colour Fonts */ --color-primary: oklch(0.55 0.21 254);
--color-text-primary: #2B2620; --color-primary-pressed: oklch(0.45 0.21 254);
--color-text-strong: #35302A; --color-secondary: oklch(0.94 0.01 250);
--color-faint-mono: #A79C8E; --color-accent: oklch(0.68 0.20 288);
--color-muted: #9A9084; --color-text: oklch(0.17 0.01 270);
--color-hover: #E7E0D2; --color-background: oklch(0.97 0.005 250);
--color-hairline: #F0EBE2; --color-surface: oklch(1 0 0);
--color-border: oklch(0.91 0.008 250);
/* Easing */
--ease-fluid: cubic-bezier(0.3, 0, 0, 1);
--ease-snappy: cubic-bezier(0.2, 0, 0, 1);
}
#range-slider .range-slider__range {
background: var(--color-primary);
}
#range-slider .range-slider__thumb {
background: var(--color-primary);
} }
:root { :root {
@@ -36,10 +42,4 @@
body { body {
background-color: var(--color-background); background-color: var(--color-background);
font-family: "IBM Plex Sans", sans-serif;
}
@utility font-heading {
font-family: "Newsreader", serif;
font-weight: 400;
} }

View File

@@ -1,16 +1,50 @@
// layout/MainLayout.jsx // layout/MainLayout.jsx
import Sidebar from '../components/Sidebar' import Sidebar from '../components/Sidebar'
import Topbar from '../components/Topbar'
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import { useState } from "react"; import {useEffect, useState} from "react";
import type {User} from "../utils/types";
import { getUser, getStreamStatus } from "../utils/endpoints";
const MainLayout = () => { const MainLayout = () => {
const [sidebarToggled] = useState(false); const [sidebarToggled, setSidebarToggled] = useState(false);
const [user, setUser] = useState<null | User>(null);
const [isStreaming, setIsStreaming] = useState(false);
const fetchUser = async () => {
try {
const userData = await getUser();
setUser(userData);
} catch (error) {
console.error('Failed to fetch user:', error);
}
};
useEffect(() => {
fetchUser();
}, []);
useEffect(() => {
if (!user) return;
const poll = () => getStreamStatus().then(setIsStreaming).catch(() => {});
poll();
const interval = setInterval(poll, 10000);
return () => clearInterval(interval);
}, [user]);
return ( return (
<div className={`transition-all duration-300 grid h-screen ${sidebarToggled ? "grid-cols-[0px_1fr]" : "grid-cols-[280px_1fr]"} grid-rows-[auto_1fr]`}> <div className={`transition-all duration-300 grid h-screen ${sidebarToggled ? "grid-cols-[0px_1fr]" : "grid-cols-[240px_1fr]"} grid-rows-[auto_1fr]`}>
<Sidebar <Sidebar
user={user}
className={`row-span-2 transition-all duration-300 overflow-hidden whitespace-nowrap ${sidebarToggled ? "-translate-x-full" : "translate-x-0"}`}/> className={`row-span-2 transition-all duration-300 overflow-hidden whitespace-nowrap ${sidebarToggled ? "-translate-x-full" : "translate-x-0"}`}/>
<div> <Topbar
className={"transition-all duration-300"}
sidebarToggled={sidebarToggled}
setSidebarToggled={setSidebarToggled}
user={user}
fetchUser={fetchUser}
isStreaming={isStreaming}/>
<div className="overflow-auto bg-background">
<Outlet /> <Outlet />
</div> </div>
</div> </div>

View File

@@ -1,13 +1,10 @@
import './index.css'
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx' import App from './App.tsx'
import {AuthProvider} from "./utils/AuthContext.tsx";
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<AuthProvider>
<App /> <App />
</AuthProvider>
</StrictMode>, </StrictMode>,
) )

View File

@@ -5,10 +5,9 @@ import PlaybackSlider from "./../components/video/PlaybackSlider";
import ClipRangeSlider from "./../components/video/ClipRangeSlider"; import ClipRangeSlider from "./../components/video/ClipRangeSlider";
import ConfigBox from "../components/video/ConfigBox.tsx"; import ConfigBox from "../components/video/ConfigBox.tsx";
import ExportWidget from "../components/video/ExportWidget.tsx"; import ExportWidget from "../components/video/ExportWidget.tsx";
import { compress } from "../utils/api/media" import { compress, getJob } from "../utils/endpoints"
import { getJob } from "../utils/api/jobs"
import type { VideoMetadata } from "../utils/types.ts"; import type { VideoMetadata } from "../utils/types.ts";
import Card from "../components/Card.tsx"; import Box from "../components/Box.tsx";
import MetadataBox from "../components/video/MetadataBox.tsx"; import MetadataBox from "../components/video/MetadataBox.tsx";
const API_URL = import.meta.env.VITE_API_URL; const API_URL = import.meta.env.VITE_API_URL;
@@ -35,7 +34,6 @@ const ClipEdit = () => {
}); });
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [downloadable, setDownloadable] = useState(false); const [downloadable, setDownloadable] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
@@ -61,10 +59,7 @@ const ClipEdit = () => {
}; };
const sendData = async () => { const sendData = async () => {
if (!localFile || uploading) return; if (!localFile) return;
setUploading(true);
setError(null);
let jobId: string; let jobId: string;
try { try {
@@ -72,7 +67,6 @@ const ClipEdit = () => {
setUploadedId(jobId); setUploadedId(jobId);
} catch (err: unknown) { } catch (err: unknown) {
setError(`Failed to start compression: ${err instanceof Error ? err.message : 'Unknown error'}`); setError(`Failed to start compression: ${err instanceof Error ? err.message : 'Unknown error'}`);
setUploading(false);
return; return;
} }
@@ -87,17 +81,14 @@ const ClipEdit = () => {
if (job.state === 'FAILED') { if (job.state === 'FAILED') {
setError(`Compression failed: ${job.errorOutput ?? 'Unknown error'}`); setError(`Compression failed: ${job.errorOutput ?? 'Unknown error'}`);
clearInterval(intervalId); clearInterval(intervalId);
setUploading(false);
} else if (job.isComplete) { } else if (job.isComplete) {
clearInterval(intervalId); clearInterval(intervalId);
setDownloadable(true); setDownloadable(true);
setUploading(false);
} }
}) })
.catch((err: Error) => { .catch((err: Error) => {
setError(`Failed to fetch progress: ${err.message}`); setError(`Failed to fetch progress: ${err.message}`);
clearInterval(intervalId); clearInterval(intervalId);
setUploading(false);
}); });
}; };
@@ -132,13 +123,13 @@ const ClipEdit = () => {
onLoadedMetadata={handleVideoMetadataLoaded} onLoadedMetadata={handleVideoMetadataLoaded}
/> />
<Card className={"w-4/5 h-full m-auto"}> <Box className={"w-4/5 h-full m-auto"}>
<MetadataBox setMetadata={setOutputMetadata} /> <MetadataBox setMetadata={setOutputMetadata} />
<ConfigBox setMetadata={setOutputMetadata} /> <ConfigBox setMetadata={setOutputMetadata} />
</Card> </Box>
{metadata && {metadata &&
<Card className={"mt-4 p-5"}> <Box className={"mt-4 p-5"}>
<Playbar <Playbar
video={videoRef.current} video={videoRef.current}
videoMetadata={metadata} videoMetadata={metadata}
@@ -166,18 +157,17 @@ const ClipEdit = () => {
{error} {error}
</div> </div>
)} )}
</Card> </Box>
} }
<Card className={"flex flex-col gap-2 w-4/5 m-auto mt-4 p-5"}> <Box className={"flex flex-col gap-2 w-4/5 m-auto mt-4 p-5"}>
<ExportWidget <ExportWidget
dataSend={sendData} dataSend={sendData}
handleDownload={handleDownload} handleDownload={handleDownload}
downloadable={downloadable} downloadable={downloadable}
progress={progress} progress={progress}
uploading={uploading}
/> />
</Card> </Box>
</div> </div>
); );
}; };

View File

@@ -1,7 +1,7 @@
import {useState} from "react"; import {useState} from "react";
import {useNavigate} from "react-router-dom"; import {useNavigate} from "react-router-dom";
import BlueButton from "../components/buttons/BlueButton.tsx"; import BlueButton from "../components/buttons/BlueButton.tsx";
import Card from "../components/Card.tsx"; import Box from "../components/Box.tsx";
const ClipUpload = () => { const ClipUpload = () => {
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
@@ -18,7 +18,7 @@ const ClipUpload = () => {
return ( return (
<div className="flex items-start justify-center p-8 min-h-full"> <div className="flex items-start justify-center p-8 min-h-full">
<Card className="flex flex-col gap-4 p-6 w-full max-w-md"> <Box className="flex flex-col gap-4 p-6 w-full max-w-md">
<h1 className="text-lg font-semibold text-gray-900">Upload Clip</h1> <h1 className="text-lg font-semibold text-gray-900">Upload Clip</h1>
<input <input
@@ -34,7 +34,7 @@ const ClipUpload = () => {
<BlueButton onClick={press}>Upload</BlueButton> <BlueButton onClick={press}>Upload</BlueButton>
{error && <p className="text-sm text-red-500 text-center">{error}</p>} {error && <p className="text-sm text-red-500 text-center">{error}</p>}
</Card> </Box>
</div> </div>
) )
}; };

View File

@@ -1,8 +1,8 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Check } from "lucide-react"; import { ArrowLeft, Check } from "lucide-react";
import { getClipById, patchClip } from "../utils/api/clips.ts"; import { getClipById, patchClip } from "../utils/endpoints.ts";
import Card from "../components/Card.tsx"; import Box from "../components/Box.tsx";
import BlueButton from "../components/buttons/BlueButton.tsx"; import BlueButton from "../components/buttons/BlueButton.tsx";
const inputClass = "border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors"; const inputClass = "border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors";
@@ -61,7 +61,7 @@ const EditClip = () => {
{loading ? ( {loading ? (
<div className="text-gray-400 text-sm">Loading...</div> <div className="text-gray-400 text-sm">Loading...</div>
) : ( ) : (
<Card className="p-6 flex flex-col gap-4"> <Box className="p-6 flex flex-col gap-4">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Title</label> <label className="text-sm font-medium text-gray-700">Title</label>
<input <input
@@ -99,7 +99,7 @@ const EditClip = () => {
Cancel Cancel
</button> </button>
</div> </div>
</Card> </Box>
)} )}
</div> </div>
); );

View File

@@ -1,108 +0,0 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Check } from "lucide-react";
import { getVodById, patchVod } from "../utils/api/vods.ts";
import Card from "../components/Card.tsx";
import BlueButton from "../components/buttons/BlueButton.tsx";
const inputClass = "border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors";
const EditVod = () => {
const { id } = useParams();
const navigate = useNavigate();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!id) return;
getVodById(id)
.then((vod) => {
if (vod) {
setTitle(vod.title ?? "");
setDescription(vod.description ?? "");
}
})
.catch(() => setError("Failed to load vod."))
.finally(() => setLoading(false));
}, [id]);
const handleSave = async () => {
if (!id) return;
setSaving(true);
setError(null);
try {
await patchVod(Number(id), { title, description });
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save.");
} finally {
setSaving(false);
}
};
return (
<div className="px-8 py-10 max-w-2xl mx-auto">
<button
onClick={() => navigate(-1)}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 mb-6 transition-colors duration-150"
>
<ArrowLeft size={16} />
Back
</button>
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Edit VoD</h1>
{loading ? (
<div className="text-gray-400 text-sm">Loading...</div>
) : (
<Card className="p-6 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Title</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter title"
className={inputClass}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Enter description"
rows={4}
className={inputClass + " resize-none"}
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex items-center gap-3 pt-1">
<BlueButton onClick={handleSave} disabled={saving} className="w-28">
{saving ? "Saving…" : saved
? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span>
: "Save"}
</BlueButton>
<button
onClick={() => navigate(-1)}
className="text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
>
Cancel
</button>
</div>
</Card>
)}
</div>
);
};
export default EditVod;

View File

@@ -1,84 +1,78 @@
import {useAuth} from "../utils/AuthContext.tsx"; import { Upload, Scissors, SlidersHorizontal, Radio, LayoutGrid, Play } from "lucide-react";
import NoAccessPage from "../components/NoAccessPage.tsx";
import {Link} from "react-router-dom";
import Card from "../components/Card.tsx";
type TimeOfDay = "morning" | "afternoon" | "evening" | "night"; const features = [
{
function getTimeOfDay(date: Date = new Date()): TimeOfDay { icon: Upload,
const hour = date.getHours(); title: "Upload & compress",
description: "Drop in a video file, set your clip range and export settings, then compress.",
if (hour >= 5 && hour < 12) return "morning"; },
if (hour >= 12 && hour < 17) return "afternoon"; {
if (hour >= 17 && hour < 22) return "evening"; icon: Scissors,
return "night"; title: "Trim",
} description: "Set start and end points with a range slider. Preview as you go.",
},
function getGreetingSubtext(date: Date = new Date()): string { {
const timeOfDay = getTimeOfDay(date); icon: SlidersHorizontal,
title: "Export settings",
const messages: Record<TimeOfDay, string[]> = { description: "Choose resolution, frame rate, and an optional file size cap.",
morning: [ },
"Good morning.", {
"Coffee first, then clips.", icon: Radio,
"Here's what's waiting for you.", title: "RTMP streaming",
], description: "Point OBS at your stream key and go live. Find the key in your profile.",
afternoon: [ },
"Good afternoon.", {
"A quiet moment for your clips.", icon: LayoutGrid,
"Catching up?", title: "Clip library",
], description: "Browse all your clips. Watch, rename, edit the description, or delete.",
evening: [ },
"Good evening.", {
"Settle in.", icon: Play,
"Time to unwind.", title: "Built-in player",
], description: "Scrub, adjust volume, go fullscreen. Runs in the browser.",
night: [ },
"Still up?", ];
"Quiet night for it.",
"Good night, when you're ready.",
]
}
const options = messages[timeOfDay];
return options[Math.floor(Math.random() * options.length)];
}
const Home = () => { const Home = () => {
const { user } = useAuth();
const now = new Date();
const formatted: string = new Intl.DateTimeFormat('en-IE', {
weekday: 'long',
day: 'numeric',
month: 'long',
}).format(now);
if (user == null) {
return (<NoAccessPage />)
}
return ( return (
<div className={"w-10/12 m-auto pt-10"}> <div className="flex flex-col items-center px-8 py-12 min-h-full">
<div className={"flex flex-row justify-between items-start pb-10"}> {/* Hero */}
<div className="flex flex-col items-center text-center max-w-2xl mb-14">
<img
src="/logo.png"
alt="VoD System Logo"
className="h-24 mb-6 object-contain"
/>
<h1 className="text-4xl font-semibold text-gray-900 mb-3 leading-tight">
A home for your clips.
</h1>
<p className="text-base text-gray-500 leading-relaxed">
Upload, trim, and export video clips. Stream live if you need to.
</p>
</div>
{/* Feature grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 w-full max-w-4xl mb-10">
{features.map(({ icon: Icon, title, description }) => (
<div
key={title}
className="bg-white border border-gray-200 rounded-xl p-5 flex flex-col gap-3 shadow-sm"
>
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Icon size={18} className="text-primary" />
</div>
<div> <div>
<p className={"font-data text-faint-mono text-sm tracking-widest"}>{formatted.toLocaleUpperCase()}</p> <p className="font-semibold text-gray-900 text-sm mb-1">{title}</p>
<h1 className={"font-heading font text-5xl text-text-strong tracking-[-0.015em]"}>{getGreetingSubtext(new Date())}</h1> <p className="text-sm text-gray-500 leading-relaxed">{description}</p>
</div> </div>
</div>
<Link to={"/stream"} className={"bg-accent rounded-md text-hover text-sm p-2 hover:bg-accent-hover shadow-md"}> ))}
Open Stream Panel
</Link>
</div>
<Card className={"bg-live-card pb-10"}>
<p></p>
</Card>
<div>
<h2 className={"font-heading text-2xl"}>Recent VoDs</h2>
</div> </div>
{/* Format note */}
<p className="text-xs text-gray-400">
Supports MP4, MOV, WebM, and OGG.
</p>
</div> </div>
); );
}; };

View File

@@ -1,7 +1,7 @@
import VideoCard from "../components/video/VideoCard"; import VideoCard from "../components/video/VideoCard";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { getClips, deleteClip } from "../utils/api/clips"; import { getClips, deleteClip } from "../utils/endpoints";
import type { Clip } from "../utils/types"; import type { Clip } from "../utils/types";
const MyClips = () => { const MyClips = () => {

View File

@@ -1,60 +0,0 @@
import VideoCard from "../components/video/VideoCard";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { getVods, deleteVod } from "../utils/api/vods";
import type { Vod } from "../utils/types";
const MyVods = () => {
const [vods, setVods] = useState<Vod[]>([]);
const [error, setError] = useState<null | string>(null);
const navigate = useNavigate();
useEffect(() => {
getVods()
.then((data) => setVods(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())))
.catch((err) => setError(err instanceof Error ? err.message : "Failed to load vods"));
}, []);
const handleDelete = async (id: number) => {
try {
await deleteVod(id);
setVods(prev => prev.filter(v => v.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete vod");
}
};
return (
<div className="p-6">
<h1 className="text-2xl font-semibold text-gray-900 mb-6">My VoDs</h1>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{vods.map((vod) => (
<VideoCard
key={vod.id}
id={vod.id}
title={vod.title}
duration={vod.duration}
createdAt={vod.createdAt}
mediaApiPath="/api/v1/vods"
playerPath="/vod"
itemLabel="vod"
onEdit={() => navigate(`/vods/${vod.id}/edit`)}
onDelete={() => handleDelete(vod.id)}
/>
))}
</div>
{vods.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<p className="text-base">No VoDs yet.</p>
<p className="text-sm mt-1">Your stream recordings will appear here.</p>
</div>
)}
{error && <p className="text-sm text-red-500 mt-4">{error}</p>}
</div>
);
};
export default MyVods;

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Eye, EyeOff, Copy, Check } from "lucide-react"; import { Eye, EyeOff, Copy, Check } from "lucide-react";
import Card from "../components/Card.tsx"; import Box from "../components/Box.tsx";
import { getUser } from "../utils/api/users.ts"; import { getUser } from "../utils/endpoints.ts";
import type { User } from "../utils/types.ts"; import type { User } from "../utils/types.ts";
import { formatLocalDate } from "../utils/utils.ts"; import { formatLocalDate } from "../utils/utils.ts";
@@ -33,7 +33,7 @@ const Profile = () => {
<div className="px-8 py-10 max-w-2xl mx-auto"> <div className="px-8 py-10 max-w-2xl mx-auto">
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Profile</h1> <h1 className="text-2xl font-semibold text-gray-900 mb-6">Profile</h1>
<Card className="p-6 mb-4 flex items-center gap-5"> <Box className="p-6 mb-4 flex items-center gap-5">
<img <img
src={user.profilePictureUrl} src={user.profilePictureUrl}
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
@@ -48,9 +48,9 @@ const Profile = () => {
Member since {formatLocalDate(user.createdAt)} Member since {formatLocalDate(user.createdAt)}
</p> </p>
</div> </div>
</Card> </Box>
<Card className="p-6"> <Box className="p-6">
<h2 className="text-sm font-semibold text-gray-800 mb-1">Stream Key</h2> <h2 className="text-sm font-semibold text-gray-800 mb-1">Stream Key</h2>
<p className="text-sm text-gray-500 mb-4"> <p className="text-sm text-gray-500 mb-4">
Use this key in OBS or any RTMP-compatible software to start streaming. Use this key in OBS or any RTMP-compatible software to start streaming.
@@ -78,7 +78,7 @@ const Profile = () => {
{copied ? <Check size={16} className="text-green-600" /> : <Copy size={16} />} {copied ? <Check size={16} className="text-green-600" /> : <Copy size={16} />}
</button> </button>
</div> </div>
</Card> </Box>
</div> </div>
); );
}; };

View File

@@ -1,399 +0,0 @@
import { useEffect, useState } from "react";
import { Radio, Check, Bookmark, Scissors, Film, Activity, Loader2, X } from "lucide-react";
import clsx from "clsx";
import Card from "../components/Card.tsx";
import BlueButton from "../components/buttons/BlueButton.tsx";
import { getUser } from "../utils/api/users.ts";
import { getCurrentStream, getStreamHistory } from "../utils/api/stream.ts";
import { getMarkers, createMarker, deleteMarker } from "../utils/api/markers.ts";
import { saveSectionByMarkers, clipSection } from "../utils/api/media.ts";
import { getJob } from "../utils/api/jobs.ts";
import type { User, StreamStatus, StreamHistoryItem, Marker, JobResponse } from "../utils/types.ts";
import { formatTime, formatLocalDate, stringToDate } from "../utils/utils.ts";
const inputClass = "border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors";
const labelClass = "text-xs font-medium text-gray-500 uppercase tracking-wide";
type TrackedJob = {
uuid: string;
label: string;
progress: number;
state: JobResponse['state'];
errorOutput: string | null;
};
const CardHeader = ({ icon: Icon, title, accent = "primary" }: { icon: React.ElementType; title: string; accent?: "primary" | "accent" }) => (
<div className="flex items-center gap-2.5">
<div className={clsx("w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0", accent === "primary" ? "bg-primary/10" : "bg-accent/10")}>
<Icon size={16} className={accent === "primary" ? "text-primary" : "text-accent"} />
</div>
<h2 className="text-sm font-semibold text-gray-800">{title}</h2>
</div>
);
const JobStatus = ({ job }: { job: TrackedJob }) => {
if (job.state === 'FAILED') {
return (
<div className="flex items-start gap-2 text-sm">
<X size={15} className="text-red-500 mt-0.5 flex-shrink-0" />
<p className="text-red-500">{job.label} failed: {job.errorOutput ?? "Unknown error"}</p>
</div>
);
}
if (job.state === 'SUCCEEDED') {
return (
<div className="flex items-center gap-2 text-sm">
<Check size={15} className="text-green-600 flex-shrink-0" />
<p className="text-gray-700">{job.label} saved</p>
</div>
);
}
return (
<div className="flex items-center gap-2">
<Loader2 size={15} className="text-primary animate-spin flex-shrink-0" />
<div className="flex-1">
<p className="text-sm text-gray-600 mb-1">{job.label}…</p>
<progress value={job.progress} className="w-full h-1.5 rounded bg-gray-200" />
</div>
</div>
);
};
const StreamControl = () => {
const [user, setUser] = useState<User | null>(null);
const [streamStatus, setStreamStatus] = useState<StreamStatus | null>(null);
const [streamDetails, setStreamDetails] = useState<StreamHistoryItem | null>(null);
const [now, setNow] = useState(Date.now());
const [markers, setMarkers] = useState<Marker[]>([]);
const [markerMessage, setMarkerMessage] = useState("");
const [markerBusy, setMarkerBusy] = useState(false);
const [markerError, setMarkerError] = useState<string | null>(null);
const [clipDuration, setClipDuration] = useState(30);
const [clipTitle, setClipTitle] = useState("");
const [clipDescription, setClipDescription] = useState("");
const [clipError, setClipError] = useState<string | null>(null);
const [startMarkerId, setStartMarkerId] = useState<string>("");
const [endMarkerId, setEndMarkerId] = useState<string>("");
const [markerSaveTitle, setMarkerSaveTitle] = useState("");
const [markerSaveDescription, setMarkerSaveDescription] = useState("");
const [markerSaveError, setMarkerSaveError] = useState<string | null>(null);
const [jobs, setJobs] = useState<TrackedJob[]>([]);
const isStreaming = streamStatus?.isStreaming ?? false;
useEffect(() => {
getUser().then(setUser).catch(() => setUser(null));
}, []);
useEffect(() => {
if (!user) return;
const poll = () => getCurrentStream().then(setStreamStatus).catch(() => {});
poll();
const interval = setInterval(poll, 5000);
return () => clearInterval(interval);
}, [user]);
useEffect(() => {
if (!user || !streamStatus?.id) {
setStreamDetails(null);
return;
}
getStreamHistory(user.id)
.then((history) => setStreamDetails(history.find((s) => s.id === streamStatus.id) ?? null))
.catch(() => {});
}, [user, streamStatus?.id]);
useEffect(() => {
if (!streamDetails?.startDate) return;
const interval = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(interval);
}, [streamDetails?.startDate]);
const refreshMarkers = () => {
getMarkers()
.then((data) => setMarkers(data
.sort((a, b) => stringToDate(a.timestamp).getTime() - stringToDate(b.timestamp).getTime())))
.catch(() => {});
};
useEffect(() => {
if (!isStreaming) {
setMarkers([]);
return;
}
refreshMarkers();
const interval = setInterval(refreshMarkers, 5000);
return () => clearInterval(interval);
}, [isStreaming, streamStatus?.id]);
useEffect(() => {
const pending = jobs.filter((j) => j.state !== 'SUCCEEDED' && j.state !== 'FAILED');
if (pending.length === 0) return;
const interval = setInterval(() => {
pending.forEach((job) => {
getJob(job.uuid)
.then((result) => {
setJobs((prev) => prev.map((j) => j.uuid === job.uuid
? { ...j, progress: result.progress, state: result.state, errorOutput: result.errorOutput }
: j));
})
.catch(() => {});
});
}, 1000);
return () => clearInterval(interval);
}, [jobs]);
const addJob = (uuid: string, label: string) => {
setJobs((prev) => [{ uuid, label, progress: 0, state: 'READY', errorOutput: null }, ...prev]);
};
const handleAddMarker = async () => {
if (!markerMessage.trim() || markerBusy) return;
setMarkerBusy(true);
setMarkerError(null);
try {
await createMarker(markerMessage.trim());
setMarkerMessage("");
refreshMarkers();
} catch (err) {
setMarkerError(err instanceof Error ? err.message : "Failed to add marker");
} finally {
setMarkerBusy(false);
}
};
const handleClip = async () => {
setClipError(null);
try {
const uuid = await clipSection(clipDuration, clipTitle || undefined, clipDescription || undefined);
addJob(uuid, `Clip (last ${clipDuration}s)`);
setClipTitle("");
setClipDescription("");
} catch (err) {
setClipError(err instanceof Error ? err.message : "Failed to clip section");
}
};
const handleSaveByMarkers = async () => {
setMarkerSaveError(null);
if (!startMarkerId || !endMarkerId) {
setMarkerSaveError("Tap two markers above to pick a start and end");
return;
}
try {
const uuid = await saveSectionByMarkers(
Number(startMarkerId),
Number(endMarkerId),
markerSaveTitle || undefined,
markerSaveDescription || undefined
);
addJob(uuid, "Save (by markers)");
setStartMarkerId("");
setEndMarkerId("");
setMarkerSaveTitle("");
setMarkerSaveDescription("");
} catch (err) {
setMarkerSaveError(err instanceof Error ? err.message : "Failed to save section");
}
};
const handleDeleteMarker = async (id: number) => {
try {
await deleteMarker(id);
if (startMarkerId === String(id)) setStartMarkerId("");
if (endMarkerId === String(id)) setEndMarkerId("");
refreshMarkers();
} catch (err) {
setMarkerError(err instanceof Error ? err.message : "Failed to delete marker");
}
};
const handleChipClick = (id: number) => {
const idStr = String(id);
setMarkerSaveError(null);
if (startMarkerId === idStr) { setStartMarkerId(""); return; }
if (endMarkerId === idStr) { setEndMarkerId(""); return; }
if (!startMarkerId) { setStartMarkerId(idStr); return; }
if (!endMarkerId) { setEndMarkerId(idStr); return; }
setEndMarkerId(idStr);
};
if (!user) {
return (
<div className="flex justify-center items-center h-full text-gray-400 text-base">
Please log in to control your stream.
</div>
);
}
const elapsed = streamDetails?.startDate
? formatTime((now - stringToDate(streamDetails.startDate).getTime()) / 1000)
: null;
const startMarker = markers.find((m) => String(m.id) === startMarkerId);
const endMarker = markers.find((m) => String(m.id) === endMarkerId);
return (
<div className="px-8 py-10 max-w-5xl mx-auto flex flex-col gap-5">
<h1 className="text-2xl font-semibold text-gray-900">Stream Control</h1>
{/* Hero status bar */}
<Card className={clsx(
"p-6 flex items-center justify-between gap-6 border-l-4",
isStreaming ? "border-l-red-500" : "border-l-gray-200"
)}>
<div className="flex items-center gap-3.5">
<span className="relative flex h-3.5 w-3.5">
{isStreaming && <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-500 opacity-75" />}
<span className={clsx("relative inline-flex rounded-full h-3.5 w-3.5", isStreaming ? "bg-red-500" : "bg-gray-300")} />
</span>
<div>
<p className="font-semibold text-gray-900 text-lg leading-tight">{isStreaming ? "Live" : "Offline"}</p>
{isStreaming && streamDetails?.startDate ? (
<p className="text-sm text-gray-500">Started {formatLocalDate(streamDetails.startDate)}</p>
) : !isStreaming ? (
<p className="text-sm text-gray-500">Point OBS at your stream key (see Profile) to go live.</p>
) : null}
</div>
</div>
{isStreaming && elapsed ? (
<div className="text-right">
<p className="text-2xl font-mono font-semibold text-gray-900 tabular-nums">{elapsed}</p>
<p className="text-xs text-gray-400 uppercase tracking-wide">elapsed</p>
</div>
) : (
<Radio size={28} className="text-gray-200" />
)}
</Card>
{isStreaming && (
<>
{/* Quick mark toolbar */}
<Card className="p-4 flex flex-col gap-3">
<div className="flex items-center gap-3">
<Bookmark size={18} className="text-accent flex-shrink-0" />
<input
type="text"
value={markerMessage}
onChange={(e) => setMarkerMessage(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAddMarker()}
placeholder="What just happened?"
className={inputClass}
/>
<BlueButton onClick={handleAddMarker} disabled={markerBusy || !markerMessage.trim()} className="whitespace-nowrap">
Add Marker
</BlueButton>
</div>
{markerError && <p className="text-sm text-red-500">{markerError}</p>}
{markers.length > 0 && (
<div className="flex gap-2 overflow-x-auto pb-0.5 -mx-1 px-1 pt-1.5">
{markers.map((m) => {
const isStart = startMarkerId === String(m.id);
const isEnd = endMarkerId === String(m.id);
return (
<div key={m.id} className="group relative flex-shrink-0">
<button
onClick={() => handleChipClick(m.id)}
className={clsx(
"flex flex-col items-start gap-0.5 rounded-lg border px-3 py-1.5 text-left transition-colors duration-150",
isStart && "border-primary bg-primary/5 ring-1 ring-primary",
isEnd && "border-accent bg-accent/5 ring-1 ring-accent",
!isStart && !isEnd && "border-gray-200 hover:border-gray-300 hover:bg-gray-50"
)}
>
<span className="text-xs font-medium text-gray-800 max-w-40 truncate">{m.message}</span>
<span className="text-[11px] text-gray-400">
{formatLocalDate(m.timestamp, { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
{isStart && <span className="text-primary font-medium"> · start</span>}
{isEnd && <span className="text-accent font-medium"> · end</span>}
</span>
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDeleteMarker(m.id); }}
title="Delete marker"
className="hidden group-hover:flex items-center justify-center absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-gray-400 hover:bg-red-500 text-white transition-colors duration-150"
>
<X size={10} />
</button>
</div>
);
})}
</div>
)}
</Card>
{/* Forefront actions */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5 items-stretch">
<Card className="p-6 flex flex-col gap-3">
<CardHeader icon={Scissors} title="Clip the last few seconds" />
<div className="flex flex-col gap-1.5">
<label className={labelClass}>Duration (seconds)</label>
<input
type="number"
min={1}
value={clipDuration}
onChange={(e) => setClipDuration(Number(e.target.value))}
className={inputClass}
/>
</div>
<input type="text" placeholder="Title" value={clipTitle} onChange={(e) => setClipTitle(e.target.value)} className={inputClass} />
<textarea placeholder="Description" value={clipDescription} onChange={(e) => setClipDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} />
{clipError && <p className="text-sm text-red-500">{clipError}</p>}
<BlueButton onClick={handleClip} className="self-start mt-auto">Clip</BlueButton>
</Card>
<Card className="p-6 flex flex-col gap-3">
<CardHeader icon={Film} title="Save between two markers" accent="accent" />
{markers.length < 2 ? (
<p className="text-sm text-gray-400">Add at least two markers above to save a section between them.</p>
) : (
<>
<div className="flex items-center gap-2 flex-wrap">
<span className={clsx(
"text-xs font-medium px-2.5 py-1 rounded-full border",
startMarker ? "border-primary text-primary bg-primary/5" : "border-dashed border-gray-300 text-gray-400"
)}>
{startMarker ? startMarker.message : "Tap a marker to set start"}
</span>
<span className="text-gray-300">→</span>
<span className={clsx(
"text-xs font-medium px-2.5 py-1 rounded-full border",
endMarker ? "border-accent text-accent bg-accent/5" : "border-dashed border-gray-300 text-gray-400"
)}>
{endMarker ? endMarker.message : "Tap a marker to set end"}
</span>
</div>
<input type="text" placeholder="Title" value={markerSaveTitle} onChange={(e) => setMarkerSaveTitle(e.target.value)} className={inputClass} />
<textarea placeholder="Description" value={markerSaveDescription} onChange={(e) => setMarkerSaveDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} />
{markerSaveError && <p className="text-sm text-red-500">{markerSaveError}</p>}
</>
)}
<BlueButton onClick={handleSaveByMarkers} disabled={markers.length < 2} className="self-start mt-auto">Save</BlueButton>
</Card>
</div>
<Card className="p-5 flex flex-col gap-3">
<CardHeader icon={Activity} title="Recent actions" />
{jobs.length === 0 ? (
<p className="text-sm text-gray-400">Clip and save actions will show up here.</p>
) : (
<div className="flex flex-col gap-3">
{jobs.map((job) => <JobStatus key={job.uuid} job={job} />)}
</div>
)}
</Card>
</>
)}
</div>
);
};
export default StreamControl;

View File

@@ -1,27 +1,12 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Lock, Maximize, Minimize, Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react"; import { ArrowLeft, Lock, Maximize, Minimize, Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react";
import { AuthError } from "../utils/api/client.ts"; import type { Clip } from "../utils/types";
import Card from "../components/Card.tsx"; import { AuthError, getClipById, getVideoBlob } from "../utils/endpoints.ts";
import Box from "../components/Box.tsx";
import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts"; import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts";
// Both VoDs and clips share the same on-screen shape. const VideoPlayer = () => {
type MediaItem = {
title: string,
createdAt: string,
};
type MediaPlayerProps = {
/** Human-readable name for the media, e.g. "VoD" or "clip". Used in messages. */
noun: string,
/** Builds the raw media stream URL for the given id. Loaded directly by the
* <video> element so the browser can issue HTTP Range requests. */
getMediaUrl: (id: string) => string,
/** Fetches the media metadata for the given id. */
fetchDetails: (id: string) => Promise<MediaItem | null>,
};
const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -31,11 +16,10 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Data // Data
const mediaUrl = id ? getMediaUrl(id) : undefined; const [videoUrl, setVideoUrl] = useState<string | undefined>(undefined);
const [mediaLoaded, setMediaLoaded] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [notAuthenticated, setNotAuthenticated] = useState(false); const [notAuthenticated, setNotAuthenticated] = useState(false);
const [item, setItem] = useState<MediaItem | null>(null); const [clip, setClip] = useState<Clip | null>(null);
const [timeAgo, setTimeAgo] = useState(""); const [timeAgo, setTimeAgo] = useState("");
// Playback // Playback
@@ -55,28 +39,28 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
// ── Data loading ──────────────────────────────────────────────────────── // ── Data loading ────────────────────────────────────────────────────────
// The video stream itself is loaded by the <video> element from `mediaUrl`,
// which lets the browser fetch it incrementally via Range requests. We only
// need to fetch the metadata here; it shares the media endpoint's auth, so a
// 401/403 here also stands in for the stream being inaccessible.
useEffect(() => { useEffect(() => {
if (!id) { setError(`${noun} ID is required.`); return; } if (!id) { setError("Clip ID is required."); return; }
fetchDetails(id) getVideoBlob(id)
.then(setItem) .then((blob) => setVideoUrl(URL.createObjectURL(blob)))
.catch((e) => { if (e instanceof AuthError) setNotAuthenticated(true); });
getClipById(id)
.then(setClip)
.catch((e) => { .catch((e) => {
if (e instanceof AuthError) setNotAuthenticated(true); if (e instanceof AuthError) setNotAuthenticated(true);
else setError(`Failed to load ${noun.toLowerCase()} details.`); else setError("Failed to load clip details.");
}); });
}, [id, noun, fetchDetails]); }, [id]);
useEffect(() => { useEffect(() => {
if (!item?.createdAt) return; if (!clip?.createdAt) return;
const update = () => setTimeAgo(dateToTimeAgo(stringToDate(item.createdAt))); const update = () => setTimeAgo(dateToTimeAgo(stringToDate(clip.createdAt)));
update(); update();
const interval = setInterval(update, 1000); const interval = setInterval(update, 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [item]); }, [clip]);
// ── Controls visibility ───────────────────────────────────────────────── // ── Controls visibility ─────────────────────────────────────────────────
const scheduleHide = useCallback(() => { const scheduleHide = useCallback(() => {
@@ -207,7 +191,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
</div> </div>
<div> <div>
<p className="text-lg font-semibold text-gray-900">Not authenticated</p> <p className="text-lg font-semibold text-gray-900">Not authenticated</p>
<p className="text-sm text-gray-500 mt-1">You don't have access to this {noun.toLowerCase()}.</p> <p className="text-sm text-gray-500 mt-1">You don't have access to this clip.</p>
</div> </div>
<button <button
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
@@ -242,10 +226,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
<video <video
ref={videoRef} ref={videoRef}
className="w-full h-full object-contain" className="w-full h-full object-contain"
src={mediaUrl} src={videoUrl}
// Send auth cookies with the cross-origin Range requests.
crossOrigin="use-credentials"
preload="metadata"
onPlay={() => { setIsPlaying(true); scheduleHide(); }} onPlay={() => { setIsPlaying(true); scheduleHide(); }}
onPause={() => { onPause={() => {
setIsPlaying(false); setIsPlaying(false);
@@ -253,17 +234,14 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
if (hideTimerRef.current) clearTimeout(hideTimerRef.current); if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
}} }}
onTimeUpdate={handleTimeUpdate} onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={() => { onLoadedMetadata={() => { if (videoRef.current) setDuration(videoRef.current.duration); }}
if (videoRef.current) setDuration(videoRef.current.duration);
setMediaLoaded(true);
}}
onWaiting={() => setIsWaiting(true)} onWaiting={() => setIsWaiting(true)}
onCanPlay={() => setIsWaiting(false)} onCanPlay={() => setIsWaiting(false)}
onError={(e) => setError(e.currentTarget.error?.message || "An error occurred.")} onError={(e) => setError(e.currentTarget.error?.message || "An error occurred.")}
/> />
{/* Buffering spinner */} {/* Buffering spinner */}
{isWaiting && mediaUrl && ( {isWaiting && videoUrl && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none"> <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="w-11 h-11 rounded-full border-[3px] border-white/20 border-t-white animate-spin" /> <div className="w-11 h-11 rounded-full border-[3px] border-white/20 border-t-white animate-spin" />
</div> </div>
@@ -332,19 +310,19 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
</div> </div>
{error && <div className="text-red-500 text-sm mt-3">{error}</div>} {error && <div className="text-red-500 text-sm mt-3">{error}</div>}
{!mediaLoaded && !error && ( {!videoUrl && !error && (
<div className="text-gray-400 text-sm mt-3 flex items-center gap-2"> <div className="text-gray-400 text-sm mt-3 flex items-center gap-2">
<div className="w-3 h-3 rounded-full border-2 border-gray-300 border-t-gray-500 animate-spin" /> <div className="w-3 h-3 rounded-full border-2 border-gray-300 border-t-gray-500 animate-spin" />
Loading video... Loading video...
</div> </div>
)} )}
<Card className="p-4 flex-shrink-0"> <Box className="p-4 flex-shrink-0">
<p className="text-xl font-semibold text-gray-900">{item?.title || "(No Title)"}</p> <p className="text-xl font-semibold text-gray-900">{clip?.title || "(No Title)"}</p>
<p className="text-sm text-gray-500 mt-1">{timeAgo}</p> <p className="text-sm text-gray-500 mt-1">{timeAgo}</p>
</Card> </Box>
</div> </div>
); );
}; };
export default MediaPlayer; export default VideoPlayer;

View File

@@ -1,42 +0,0 @@
import React, {createContext, useContext, useState, useEffect, type ReactNode} from 'react';
import { getUser } from "./api/users.ts";
import type {User} from "./types.ts";
// for what the Auth object will look like
interface AuthContextValue {
user: User | null;
setUser: React.Dispatch<React.SetStateAction<User | null>>;
loading: boolean;
}
interface AuthProviderProps {
children: ReactNode;
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export function AuthProvider({children}: AuthProviderProps) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
getUser()
.then(setUser)
.catch(() => setUser(null))
.finally(() => setLoading(false));
}, []);
return (
<AuthContext.Provider value={{ user, setUser, loading }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@@ -1,12 +0,0 @@
const API_URL = import.meta.env.VITE_API_URL;
class AuthError extends Error {
constructor() { super("Not authenticated"); this.name = "AuthError"; }
}
const isThumbnailAvailable = async (thumbnailUrl: string): Promise<boolean> => {
const response = await fetch(thumbnailUrl, { credentials: 'include' });
return response.ok;
};
export { API_URL, AuthError, isThumbnailAvailable };

View File

@@ -1,61 +0,0 @@
import type { APIResponse, Clip } from "../types.ts";
import { API_URL, AuthError } from "./client.ts";
const getClips = async (): Promise<Clip[]> => {
const response = await fetch(API_URL + '/api/v1/clips', { credentials: 'include' });
if (!response.ok) {
const errorResult: APIResponse = await response.json();
throw new Error(`Failed to fetch clips: ${errorResult.message}`);
}
const result: APIResponse = await response.json();
return result.data;
};
const getClipById = async (id: string): Promise<Clip | null> => {
const response = await fetch(API_URL + `/api/v1/clips/${id}`, { credentials: 'include' });
if (!response.ok) {
if (response.status === 401 || response.status === 403) throw new AuthError();
throw new Error(`Failed to fetch clip: ${response.status}`);
}
const result: APIResponse = await response.json();
return result.data;
};
// URL for the raw media stream. The <video> element loads this directly so the
// browser can issue HTTP Range requests (progressive loading + seeking) instead
// of downloading the whole file up front.
const getClipMediaUrl = (id: string): string => API_URL + `/api/v1/clips/${id}/media`;
const patchClip = async (id: number, data: { title?: string; description?: string }): Promise<Clip> => {
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data),
});
if (!response.ok) throw new Error(`Failed to update clip: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to update clip: ${result.message}`);
return result.data;
};
const deleteClip = async (id: number): Promise<void> => {
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) throw new Error(`Failed to delete clip: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to delete clip: ${result.message}`);
};
export { getClips, getClipById, getClipMediaUrl, patchClip, deleteClip };

View File

@@ -1,15 +0,0 @@
import type { APIResponse, JobResponse } from "../types.ts";
import { API_URL } from "./client.ts";
const getJob = async (uuid: string): Promise<JobResponse> => {
const response = await fetch(API_URL + `/api/v1/jobs/${uuid}`, { credentials: 'include' });
if (!response.ok) throw new Error(`Failed to fetch job: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to fetch job: ${result.message}`);
return result.data;
};
export { getJob };

View File

@@ -1,40 +0,0 @@
import type { APIResponse, Marker } from "../types.ts";
import { API_URL } from "./client.ts";
const getMarkers = async (): Promise<Marker[]> => {
const response = await fetch(API_URL + '/api/v1/markers', { credentials: 'include' });
if (!response.ok) throw new Error(`Failed to fetch markers: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to fetch markers: ${result.message}`);
return result.data;
};
const createMarker = async (message: string): Promise<Marker> => {
const response = await fetch(API_URL + '/api/v1/markers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ message }),
});
const result: APIResponse = await response.json();
if (!response.ok || result.status === 'error') throw new Error(`Failed to add marker: ${result.message}`);
return result.data;
};
const deleteMarker = async (id: number): Promise<void> => {
const response = await fetch(API_URL + `/api/v1/markers/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) throw new Error(`Failed to delete marker: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to delete marker: ${result.message}`);
};
export { getMarkers, createMarker, deleteMarker };

View File

@@ -1,72 +0,0 @@
import type { APIResponse, VideoMetadata } from "../types.ts";
import { API_URL } from "./client.ts";
const compress = async (file: File, options: VideoMetadata): Promise<string> => {
const formData = new FormData();
formData.append('file', file);
formData.append('title', options.title);
if (options.description) formData.append('description', options.description);
formData.append('startPoint', options.startPoint.toString());
formData.append('duration', options.duration.toString());
if (options.fps) formData.append('fps', options.fps.toString());
if (options.width) formData.append('width', options.width.toString());
if (options.height) formData.append('height', options.height.toString());
if (options.fileSize) formData.append('fileSize', options.fileSize.toString());
const response = await fetch(API_URL + '/api/v1/media/compress', {
method: 'POST',
body: formData,
credentials: 'include',
});
if (!response.ok) throw new Error(`Compression failed: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Compression failed: ${result.message}`);
return result.data.uuid;
};
const saveSection = async (startTime: string, endTime: string, title?: string, description?: string): Promise<string> => {
const response = await fetch(API_URL + '/api/v1/media/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ startTime, endTime, title, description }),
});
const result: APIResponse = await response.json();
if (!response.ok || result.status === 'error') throw new Error(`Failed to save section: ${result.message}`);
return result.data.uuid;
};
const saveSectionByMarkers = async (startMarkerId: number, endMarkerId: number, title?: string, description?: string): Promise<string> => {
const response = await fetch(API_URL + '/api/v1/media/save/markers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ startMarkerId, endMarkerId, title, description }),
});
const result: APIResponse = await response.json();
if (!response.ok || result.status === 'error') throw new Error(`Failed to save section: ${result.message}`);
return result.data.uuid;
};
const clipSection = async (duration: number, title?: string, description?: string): Promise<string> => {
const response = await fetch(API_URL + '/api/v1/media/clip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ duration, title, description }),
});
const result: APIResponse = await response.json();
if (!response.ok || result.status === 'error') throw new Error(`Failed to clip section: ${result.message}`);
return result.data.uuid;
};
export { compress, saveSection, saveSectionByMarkers, clipSection };

View File

@@ -1,31 +0,0 @@
import type { APIResponse, StreamHistoryItem, StreamStatus } from "../types.ts";
import { API_URL } from "./client.ts";
const getStreamStatus = async (): Promise<boolean> => {
const response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' });
if (!response.ok) return false;
const result: APIResponse = await response.json();
return result.status === 'success' && result.data?.isStreaming === true;
};
const getCurrentStream = async (): Promise<StreamStatus> => {
const response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' });
if (!response.ok) throw new Error(`Failed to fetch stream status: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to fetch stream status: ${result.message}`);
return result.data;
};
const getStreamHistory = async (userId: number): Promise<StreamHistoryItem[]> => {
const response = await fetch(API_URL + `/api/v1/stream/history/${userId}`, { credentials: 'include' });
if (!response.ok) throw new Error(`Failed to fetch stream history: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to fetch stream history: ${result.message}`);
return result.data;
};
export { getStreamStatus, getCurrentStream, getStreamHistory };

View File

@@ -1,42 +0,0 @@
import type { APIResponse, User } from "../types.ts";
import { API_URL } from "./client.ts";
const login = async (googleToken: string): Promise<string> => {
const response = await fetch(API_URL + '/api/v1/users/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: googleToken }),
credentials: 'include',
});
if (!response.ok) throw new Error(`Login failed: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Login failed: ${result.message}`);
return result.data.token;
};
const logout = async (): Promise<void> => {
const response = await fetch(API_URL + '/api/v1/users/logout', {
method: 'POST',
credentials: 'include',
});
if (!response.ok) throw new Error(`Logout failed: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Logout failed: ${result.message}`);
};
const getUser = async (): Promise<User | null> => {
const response = await fetch(API_URL + '/api/v1/users/me', { credentials: 'include' });
if (!response.ok) return null;
const result: APIResponse = await response.json();
if (result.status === 'error') return null;
return result.data;
};
export { login, logout, getUser };

View File

@@ -1,61 +0,0 @@
import type { APIResponse, Vod } from "../types.ts";
import { API_URL, AuthError } from "./client.ts";
const getVods = async (): Promise<Vod[]> => {
const response = await fetch(API_URL + '/api/v1/vods', { credentials: 'include' });
if (!response.ok) {
const errorResult: APIResponse = await response.json();
throw new Error(`Failed to fetch vods: ${errorResult.message}`);
}
const result: APIResponse = await response.json();
return result.data;
};
const getVodById = async (id: string): Promise<Vod | null> => {
const response = await fetch(API_URL + `/api/v1/vods/${id}`, { credentials: 'include' });
if (!response.ok) {
if (response.status === 401 || response.status === 403) throw new AuthError();
throw new Error(`Failed to fetch vod: ${response.status}`);
}
const result: APIResponse = await response.json();
return result.data;
};
// URL for the raw media stream. The <video> element loads this directly so the
// browser can issue HTTP Range requests (progressive loading + seeking) instead
// of downloading the whole file up front.
const getVodMediaUrl = (id: string): string => API_URL + `/api/v1/vods/${id}/media`;
const patchVod = async (id: number, data: { title?: string; description?: string }): Promise<Vod> => {
const response = await fetch(API_URL + `/api/v1/vods/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data),
});
if (!response.ok) throw new Error(`Failed to update vod: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to update vod: ${result.message}`);
return result.data;
};
const deleteVod = async (id: number): Promise<void> => {
const response = await fetch(API_URL + `/api/v1/vods/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) throw new Error(`Failed to delete vod: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to delete vod: ${result.message}`);
};
export { getVods, getVodById, getVodMediaUrl, patchVod, deleteVod };

View File

@@ -0,0 +1,172 @@
import type { VideoMetadata, APIResponse, User, Clip, JobResponse } from "./types.ts";
const API_URL = import.meta.env.VITE_API_URL;
export class AuthError extends Error {
constructor() { super("Not authenticated"); this.name = "AuthError"; }
}
const login = async (googleToken: string): Promise<string> => {
const response = await fetch(API_URL + '/api/v1/users/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: googleToken }),
credentials: 'include',
});
if (!response.ok) throw new Error(`Login failed: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Login failed: ${result.message}`);
return result.data.token;
};
const logout = async (): Promise<void> => {
const response = await fetch(API_URL + '/api/v1/users/logout', {
method: 'POST',
credentials: 'include',
});
if (!response.ok) throw new Error(`Logout failed: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Logout failed: ${result.message}`);
};
const compress = async (file: File, options: VideoMetadata): Promise<string> => {
const formData = new FormData();
formData.append('file', file);
formData.append('title', options.title);
if (options.description) formData.append('description', options.description);
formData.append('startPoint', options.startPoint.toString());
formData.append('duration', options.duration.toString());
if (options.fps) formData.append('fps', options.fps.toString());
if (options.width) formData.append('width', options.width.toString());
if (options.height) formData.append('height', options.height.toString());
if (options.fileSize) formData.append('fileSize', options.fileSize.toString());
const response = await fetch(API_URL + '/api/v1/media/compress', {
method: 'POST',
body: formData,
credentials: 'include',
});
if (!response.ok) throw new Error(`Compression failed: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Compression failed: ${result.message}`);
return result.data.uuid;
};
const getJob = async (uuid: string): Promise<JobResponse> => {
const response = await fetch(API_URL + `/api/v1/jobs/${uuid}`, { credentials: 'include' });
if (!response.ok) throw new Error(`Failed to fetch job: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to fetch job: ${result.message}`);
return result.data;
};
const getUser = async (): Promise<User | null> => {
const response = await fetch(API_URL + '/api/v1/users/me', { credentials: 'include' });
if (!response.ok) return null;
const result: APIResponse = await response.json();
if (result.status === 'error') return null;
return result.data;
};
const getClips = async (): Promise<Clip[]> => {
const response = await fetch(API_URL + '/api/v1/clips', { credentials: 'include' });
if (!response.ok) {
const errorResult: APIResponse = await response.json();
throw new Error(`Failed to fetch clips: ${errorResult.message}`);
}
const result: APIResponse = await response.json();
return result.data;
};
const getClipById = async (id: string): Promise<Clip | null> => {
const response = await fetch(API_URL + `/api/v1/clips/${id}`, { credentials: 'include' });
if (!response.ok) {
if (response.status === 401 || response.status === 403) throw new AuthError();
throw new Error(`Failed to fetch clip: ${response.status}`);
}
const result: APIResponse = await response.json();
return result.data;
};
const getVideoBlob = async (id: string): Promise<Blob> => {
const response = await fetch(API_URL + `/api/v1/clips/${id}/media`, { credentials: 'include' });
if (!response.ok) {
if (response.status === 401 || response.status === 403) throw new AuthError();
throw new Error(`Failed to fetch video ${id}: ${response.status}`);
}
return response.blob();
};
const patchClip = async (id: number, data: { title?: string; description?: string }): Promise<Clip> => {
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data),
});
if (!response.ok) throw new Error(`Failed to update clip: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to update clip: ${result.message}`);
return result.data;
};
const deleteClip = async (id: number): Promise<void> => {
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) throw new Error(`Failed to delete clip: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to delete clip: ${result.message}`);
};
const getStreamStatus = async (): Promise<boolean> => {
const response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' });
if (!response.ok) return false;
const result: APIResponse = await response.json();
return result.status === 'success' && result.data?.isStreaming === true;
};
const isThumbnailAvailable = async (id: number): Promise<boolean> => {
const response = await fetch(API_URL + `/api/v1/clips/${id}/thumbnail`, { credentials: 'include' });
return response.ok;
};
export {
login,
logout,
compress,
getJob,
getUser,
getClips,
getClipById,
getVideoBlob,
patchClip,
deleteClip,
getStreamStatus,
isThumbnailAvailable,
};

View File

@@ -16,7 +16,6 @@ type APIResponse = {
} }
type User = { type User = {
id: number,
name: string, name: string,
username: string, username: string,
email: string, email: string,
@@ -34,15 +33,6 @@ type Clip = {
createdAt: string, createdAt: string,
} }
type Vod = {
id: number,
userId: number,
title: string,
description: string,
duration: number,
createdAt: string,
}
type JobResponse = { type JobResponse = {
uuid: string, uuid: string,
progress: number, progress: number,
@@ -52,34 +42,10 @@ type JobResponse = {
createdAt: string, createdAt: string,
}; };
type StreamStatus = {
isStreaming: boolean,
id: number | null,
}
type StreamHistoryItem = {
id: number,
startDate: string | null,
endDate: string | null,
lastSeen: string | null,
}
type Marker = {
id: number,
userId: number,
streamId: number,
message: string,
timestamp: string,
}
export type { export type {
APIResponse, APIResponse,
VideoMetadata, VideoMetadata,
User, User,
Clip, Clip,
Vod, JobResponse
JobResponse,
StreamStatus,
StreamHistoryItem,
Marker
} }

View File

@@ -1,12 +1,11 @@
import { defineConfig, loadEnv } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import flowbiteReact from "flowbite-react/plugin/vite"; import flowbiteReact from "flowbite-react/plugin/vite";
export default defineConfig(({ mode }) => { // change to function form
const env = loadEnv(mode, process.cwd(), '') // load all env vars
return { // https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss(), flowbiteReact()], plugins: [react(), tailwindcss(), flowbiteReact()],
preview: { preview: {
port: 5173, port: 5173,
@@ -16,8 +15,7 @@ export default defineConfig(({ mode }) => { // change to function form
port: 5173, port: 5173,
strictPort: true, strictPort: true,
host: true, host: true,
origin: env.VITE_FRONTEND_URL || "http://0.0.0.0:5173", // use it here origin: "http://0.0.0.0:5173",
allowedHosts: true,
proxy: { proxy: {
'/api/v1': { '/api/v1': {
target: 'http://localhost:8080', target: 'http://localhost:8080',
@@ -26,5 +24,4 @@ export default defineConfig(({ mode }) => { // change to function form
} }
} }
} }
}
}) })

50
mvnw vendored
View File

@@ -19,7 +19,7 @@
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4 # Apache Maven Wrapper startup batch script, version 3.3.2
# #
# Optional ENV vars # Optional ENV vars
# ----------------- # -----------------
@@ -105,17 +105,14 @@ trim() {
printf "%s" "${1}" | tr -d '[:space:]' printf "%s" "${1}" | tr -d '[:space:]'
} }
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties # parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do while IFS="=" read -r key value; do
case "${key-}" in case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;; distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" [ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in case "${distributionUrl##*/}" in
maven-mvnd-*bin.*) maven-mvnd-*bin.*)
@@ -133,7 +130,7 @@ maven-mvnd-*bin.*)
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;; ;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; *) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac esac
# apply MVNW_REPOURL and calculate MAVEN_HOME # apply MVNW_REPOURL and calculate MAVEN_HOME
@@ -230,7 +227,7 @@ if [ -n "${distributionSha256Sum-}" ]; then
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1 exit 1
elif command -v sha256sum >/dev/null; then elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then
distributionSha256Result=true distributionSha256Result=true
fi fi
elif command -v shasum >/dev/null; then elif command -v shasum >/dev/null; then
@@ -255,41 +252,8 @@ if command -v unzip >/dev/null; then
else else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi fi
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url"
# Find the actual extracted directory name (handles snapshots where filename != directory name) mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || : clean || :
exec_maven "$@" exec_maven "$@"

56
mvnw.cmd vendored
View File

@@ -19,7 +19,7 @@
@REM ---------------------------------------------------------------------------- @REM ----------------------------------------------------------------------------
@REM ---------------------------------------------------------------------------- @REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4 @REM Apache Maven Wrapper startup batch script, version 3.3.2
@REM @REM
@REM Optional ENV vars @REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution @REM MVNW_REPOURL - repo url base for downloading maven distribution
@@ -40,7 +40,7 @@
@SET __MVNW_ARG0_NAME__= @SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME= @SET MVNW_USERNAME=
@SET MVNW_PASSWORD= @SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) @IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1 @echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF @GOTO :EOF
: end batch / begin powershell #> : end batch / begin powershell #>
@@ -73,30 +73,16 @@ switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
# apply MVNW_REPOURL and calculate MAVEN_HOME # apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash> # maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) { if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')"
} }
$distributionUrlName = $distributionUrl -replace '^.*/','' $distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' $distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain"
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) { if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain"
} }
$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" $MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
@@ -148,33 +134,7 @@ if ($distributionSha256Sum) {
# unzip and move # unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try { try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch { } catch {

View File

@@ -1,9 +1,6 @@
FROM tiangolo/nginx-rtmp FROM tiangolo/nginx-rtmp
RUN apt-get update && \ RUN apt-get update && apt-get install -y gettext-base && rm -rf /var/lib/apt/lists/*
apt-get install -y --no-install-recommends gettext-base && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
COPY default.conf.template /etc/nginx/templates/default.conf.template COPY default.conf.template /etc/nginx/templates/default.conf.template
COPY docker-entrypoint.sh /docker-entrypoint.sh COPY docker-entrypoint.sh /docker-entrypoint.sh

View File

@@ -20,7 +20,7 @@ rtmp {
hls_path /stream; hls_path /stream;
hls_fragment_naming system; hls_fragment_naming system;
hls_fragment 3; hls_fragment 3;
hls_playlist_length ${STREAM_RING_LENGTH_SECOND}; hls_playlist_length 28800;
on_publish ${WEB_SERVER_URL}/api/v1/stream/start; on_publish ${WEB_SERVER_URL}/api/v1/stream/start;
on_publish_done ${WEB_SERVER_URL}/api/v1/stream/stop; on_publish_done ${WEB_SERVER_URL}/api/v1/stream/stop;

View File

@@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
# must only run on certain env files, otherwise it replaces too much # must only run on certain env files, otherwise it replaces too much
envsubst '${WEB_SERVER_URL} ${STREAM_RING_LENGTH_SECOND}' \ envsubst '${WEB_SERVER_URL}' \
< /etc/nginx/templates/default.conf.template \ < /etc/nginx/templates/default.conf.template \
> /etc/nginx/nginx.conf > /etc/nginx/nginx.conf

View File

@@ -85,11 +85,6 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId> <artifactId>spring-boot-starter-validation</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

@@ -2,17 +2,19 @@
FROM maven:3.9.6-eclipse-temurin-21 AS builder FROM maven:3.9.6-eclipse-temurin-21 AS builder
WORKDIR /app WORKDIR /app
COPY pom.xml . COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 mvn dependency:go-offline -B
COPY src ./src COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn clean package -DskipTests -B RUN mvn clean package -DskipTests
# Run stage # Run stage
FROM eclipse-temurin:21-jre FROM eclipse-temurin:21-jdk
# dependencies # dependencies
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends ffmpeg && \ apt-get install -y ffmpeg && \
apt-get clean && \ apt-get clean && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080 EXPOSE 8080

View File

@@ -12,8 +12,8 @@ import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.List; import java.util.List;
import java.util.Optional;
@RestController @RestController
@RequestMapping("/api/v1/clips") @RequestMapping("/api/v1/clips")
@@ -43,8 +43,12 @@ public class ClipController {
@GetMapping("/{id}") @GetMapping("/{id}")
public ResponseEntity<APIResponse<ClipResponse>> getClipById(@PathVariable Long id) { public ResponseEntity<APIResponse<ClipResponse>> getClipById(@PathVariable Long id) {
Clip clip = clipService.getClipById(id); Optional<Clip> clip = clipService.getClipById(id);
ClipResponse clipDTO = convertToDTO(clip); if (clip.isEmpty()) {
return ResponseEntity.notFound().build();
}
ClipResponse clipDTO = convertToDTO(clip.get());
return ResponseEntity.ok( return ResponseEntity.ok(
new APIResponse<>(SUCCESS, new APIResponse<>(SUCCESS,
@@ -67,7 +71,9 @@ public class ClipController {
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public ResponseEntity<APIResponse<String>> deleteClip(@PathVariable Long id) { public ResponseEntity<APIResponse<String>> deleteClip(@PathVariable Long id) {
clipService.deleteClip(id); if (!clipService.deleteClip(id)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok( return ResponseEntity.ok(
new APIResponse<>( new APIResponse<>(
@@ -78,8 +84,8 @@ public class ClipController {
); );
} }
@GetMapping(value = "/{id}/media", produces = MediaType.ALL_VALUE) @GetMapping("/{id}/media")
public ResponseEntity<Resource> downloadClip(@PathVariable Long id) throws IOException { public ResponseEntity<Resource> downloadClip(@PathVariable Long id) {
Resource resource = clipService.downloadClip(id); Resource resource = clipService.downloadClip(id);
if (resource == null || !resource.exists()) { if (resource == null || !resource.exists()) {
@@ -87,11 +93,8 @@ public class ClipController {
} }
return ResponseEntity.ok() return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, .header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
String.format(FILENAME_HEADER, resource.getFilename())) .contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.contentType(MediaTypeFactory.getMediaType(resource)
.orElse(MediaType.APPLICATION_OCTET_STREAM))
.contentLength(resource.contentLength())
.body(resource); .body(resource);
} }

View File

@@ -94,46 +94,4 @@ public class GlobalExceptionHandler {
APIResponse<Void> response = new APIResponse<>(ERROR, "Could not process file", null); APIResponse<Void> response = new APIResponse<>(ERROR, "Could not process file", null);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
} }
@ExceptionHandler(ClipNotFound.class)
public ResponseEntity<APIResponse<Void>> handleClipNotFound(ClipNotFound ex) {
logger.error("ClipNotFound: {}", ex.getMessage());
APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}
@ExceptionHandler(ClipMediaUnavailable.class)
public ResponseEntity<APIResponse<Void>> handleClipMediaUnavailable(ClipMediaUnavailable ex) {
logger.error("ClipMediaUnavailable (clip {}): {}", ex.getClipId(), ex.getMessage());
APIResponse<Void> response = new APIResponse<>(ERROR, "Clip media is currently unavailable", null);
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(response);
}
@ExceptionHandler(VodNotFound.class)
public ResponseEntity<APIResponse<Void>> handleVodNotFound(VodNotFound ex) {
logger.error("VodNotFound: {}", ex.getMessage());
APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}
@ExceptionHandler(VodMediaUnavailable.class)
public ResponseEntity<APIResponse<Void>> handleVodMediaUnavailable(VodMediaUnavailable ex) {
logger.error("VodMediaUnavailable (vod {}): {}", ex.getVodId(), ex.getMessage());
APIResponse<Void> response = new APIResponse<>(ERROR, "Vod media is currently unavailable", null);
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(response);
}
@ExceptionHandler(NotStreaming.class)
public ResponseEntity<APIResponse<Void>> handleNotStreaming(NotStreaming ex) {
logger.error("NotStreaming: {}", ex.getMessage());
APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null);
return ResponseEntity.status(HttpStatus.CONFLICT).body(response);
}
@ExceptionHandler(MarkerNotFound.class)
public ResponseEntity<APIResponse<Void>> handleMarkerNotFound(MarkerNotFound ex) {
logger.error("MarkerNotFound: {}", ex.getMessage());
APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}
} }

View File

@@ -1,72 +0,0 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.controllers.dto.MarkerCreateRequest;
import com.ddf.vodsystem.controllers.dto.MarkerResponse;
import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.entities.Marker;
import com.ddf.vodsystem.services.MarkerService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/v1/markers")
public class MarkerController {
private static final String SUCCESS = "success";
private final MarkerService markerService;
public MarkerController(MarkerService markerService) {
this.markerService = markerService;
}
@GetMapping("")
public ResponseEntity<APIResponse<List<MarkerResponse>>> getMarkers() {
List<MarkerResponse> markers = markerService.getUserMarkers().stream()
.map(this::convertToDTO)
.toList();
return ResponseEntity.ok(
new APIResponse<>(SUCCESS, "Markers retrieved successfully", markers)
);
}
@GetMapping("/{id}")
public ResponseEntity<APIResponse<MarkerResponse>> getMarkerById(@PathVariable Long id) {
Marker marker = markerService.getMarkerById(id);
return ResponseEntity.ok(
new APIResponse<>(SUCCESS, "Marker retrieved successfully", convertToDTO(marker))
);
}
@PostMapping("")
public ResponseEntity<APIResponse<MarkerResponse>> createMarker(@Valid @RequestBody MarkerCreateRequest request) {
Marker marker = markerService.create(request.message());
return ResponseEntity.ok(
new APIResponse<>(SUCCESS, "Marker created successfully", convertToDTO(marker))
);
}
@DeleteMapping("/{id}")
public ResponseEntity<APIResponse<String>> deleteMarker(@PathVariable Long id) {
markerService.deleteMarker(id);
return ResponseEntity.ok(
new APIResponse<>(SUCCESS, "Marker deleted successfully", "Marker " + id + " deleted.")
);
}
private MarkerResponse convertToDTO(Marker marker) {
return new MarkerResponse(
marker.getId(),
marker.getUser().getId(),
marker.getStream().getId(),
marker.getMessage(),
marker.getTimestamp()
);
}
}

View File

@@ -1,7 +1,6 @@
package com.ddf.vodsystem.controllers; package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.controllers.dto.ClipSectionRequest; import com.ddf.vodsystem.controllers.dto.ClipSectionRequest;
import com.ddf.vodsystem.controllers.dto.SaveSectionByMarkerRequest;
import com.ddf.vodsystem.controllers.dto.SaveSectionRequest; import com.ddf.vodsystem.controllers.dto.SaveSectionRequest;
import com.ddf.vodsystem.controllers.dto.UUIDResponse; import com.ddf.vodsystem.controllers.dto.UUIDResponse;
import com.ddf.vodsystem.dto.Job; import com.ddf.vodsystem.dto.Job;
@@ -30,7 +29,7 @@ public class MediaController {
public ResponseEntity<APIResponse<UUIDResponse>> compress( public ResponseEntity<APIResponse<UUIDResponse>> compress(
@RequestParam("file")MultipartFile file, @RequestParam("file")MultipartFile file,
@Valid @ModelAttribute ClipOptions options @Valid @ModelAttribute ClipOptions options
) throws IOException { ) throws IOException, InterruptedException {
Job job = mediaService.compress(file, options); Job job = mediaService.compress(file, options);
return ResponseEntity.ok(new APIResponse<>( return ResponseEntity.ok(new APIResponse<>(
SUCCESS, SUCCESS,
@@ -43,26 +42,7 @@ public class MediaController {
@PostMapping("/save") @PostMapping("/save")
public ResponseEntity<APIResponse<UUIDResponse>> save( public ResponseEntity<APIResponse<UUIDResponse>> save(
@RequestBody SaveSectionRequest saveSectionRequest) throws IOException { @RequestBody SaveSectionRequest saveSectionRequest) throws IOException {
Job job = mediaService.saveSection( Job job = mediaService.saveSection(saveSectionRequest.startTime(), saveSectionRequest.endTime());
saveSectionRequest.startTime(),
saveSectionRequest.endTime(),
saveSectionRequest.title(),
saveSectionRequest.description());
return ResponseEntity.ok(new APIResponse<>(
SUCCESS,
"Section saving successfully started",
new UUIDResponse(job.getUuid())
));
}
@PostMapping("/save/markers")
public ResponseEntity<APIResponse<UUIDResponse>> saveByMarkers(
@Valid @RequestBody SaveSectionByMarkerRequest saveSectionByMarkerRequest) throws IOException {
Job job = mediaService.saveSection(
saveSectionByMarkerRequest.startMarkerId(),
saveSectionByMarkerRequest.endMarkerId(),
saveSectionByMarkerRequest.title(),
saveSectionByMarkerRequest.description());
return ResponseEntity.ok(new APIResponse<>( return ResponseEntity.ok(new APIResponse<>(
SUCCESS, SUCCESS,
"Section saving successfully started", "Section saving successfully started",
@@ -73,11 +53,7 @@ public class MediaController {
@PostMapping("/clip") @PostMapping("/clip")
public ResponseEntity<APIResponse<UUIDResponse>> clip( public ResponseEntity<APIResponse<UUIDResponse>> clip(
@RequestBody ClipSectionRequest clipSectionRequest) throws IOException { @RequestBody ClipSectionRequest clipSectionRequest) throws IOException {
Job job = mediaService.clip( Job job = mediaService.clip(clipSectionRequest.duration());
clipSectionRequest.duration(),
clipSectionRequest.title(),
clipSectionRequest.description()
);
return ResponseEntity.ok(new APIResponse<>( return ResponseEntity.ok(new APIResponse<>(
SUCCESS, SUCCESS,
"Clipping successfully started", "Clipping successfully started",

View File

@@ -6,7 +6,6 @@ import com.ddf.vodsystem.entities.User;
import com.ddf.vodsystem.exceptions.NotAuthenticated; import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.services.UserService; import com.ddf.vodsystem.services.UserService;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -42,15 +41,14 @@ public class UserController {
@PostMapping("/login") @PostMapping("/login")
public ResponseEntity<APIResponse<Token>> login(@RequestBody Token token, public ResponseEntity<APIResponse<Token>> login(@RequestBody Token token,
HttpServletRequest request,
HttpServletResponse response) { HttpServletResponse response) {
String jwt = userService.login(token.token()); String jwt = userService.login(token.token());
ResponseCookie cookie = ResponseCookie.from("token", jwt) ResponseCookie cookie = ResponseCookie.from("token", jwt)
.httpOnly(true) .httpOnly(true)
.maxAge(jwtExpiration / 1000) .maxAge(jwtExpiration / 1000)
.sameSite(request.isSecure() ? "None" : "Lax") .sameSite("None")
.secure(request.isSecure()) .secure(true)
.path("/") .path("/")
.build(); .build();
@@ -62,13 +60,12 @@ public class UserController {
} }
@PostMapping("/logout") @PostMapping("/logout")
public ResponseEntity<APIResponse<Void>> logout(HttpServletResponse response, public ResponseEntity<APIResponse<Void>> logout(HttpServletResponse response) {
HttpServletRequest request) {
ResponseCookie cookie = ResponseCookie.from("token", "") ResponseCookie cookie = ResponseCookie.from("token", "")
.httpOnly(true) .httpOnly(true)
.maxAge(0) .maxAge(0)
.sameSite(request.isSecure() ? "None" : "Lax") .sameSite("None")
.secure(request.isSecure()) .secure(true)
.path("/") .path("/")
.build(); .build();

View File

@@ -1,99 +0,0 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.controllers.dto.VodResponse;
import com.ddf.vodsystem.controllers.dto.VodUpdateRequest;
import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.entities.Vod;
import com.ddf.vodsystem.services.VodService;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.List;
@RestController
@RequestMapping("/api/v1/vods")
public class VodController {
private final VodService vodService;
private static final String SUCCESS = "success";
private static final String FILENAME_HEADER = "inline; filename=\"%s\"";
public VodController(VodService vodService) {
this.vodService = vodService;
}
@GetMapping("")
public ResponseEntity<APIResponse<List<VodResponse>>> getVods() {
List<VodResponse> vods = vodService.getUserVods().stream()
.map(this::convertToDTO)
.toList();
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vods retrieved successfully", vods));
}
@GetMapping("/{id}")
public ResponseEntity<APIResponse<VodResponse>> getVodById(@PathVariable Long id) {
VodResponse vod = convertToDTO(vodService.getVodById(id));
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vod retrieved successfully", vod));
}
@PatchMapping("/{id}")
public ResponseEntity<APIResponse<VodResponse>> updateVod(@PathVariable Long id,
@RequestBody VodUpdateRequest updateFields) {
VodResponse vod = convertToDTO(vodService.updateVod(id, updateFields));
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vod updated successfully", vod));
}
@DeleteMapping("/{id}")
public ResponseEntity<APIResponse<String>> deleteVod(@PathVariable Long id) {
vodService.deleteVod(id);
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vod deleted successfully",
"Vod with ID " + id + " has been deleted"));
}
@GetMapping(value = "/{id}/media", produces = MediaType.ALL_VALUE)
public ResponseEntity<Resource> downloadVod(@PathVariable Long id) throws IOException {
Resource resource = vodService.downloadVod(id);
if (resource == null || !resource.exists()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource)
.orElse(MediaType.APPLICATION_OCTET_STREAM))
.contentLength(resource.contentLength())
.body(resource);
}
@GetMapping("/{id}/thumbnail")
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) {
Resource resource = vodService.downloadThumbnail(id);
if (resource == null || !resource.exists()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource);
}
private VodResponse convertToDTO(Vod vod) {
return new VodResponse(
vod.getId(),
vod.getUser().getId(),
vod.getTitle(),
vod.getDescription(),
vod.getDuration(),
vod.getCreatedAt()
);
}
}

View File

@@ -3,7 +3,5 @@ package com.ddf.vodsystem.controllers.dto;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
public record ClipSectionRequest( public record ClipSectionRequest(
@NotNull float duration, @NotNull float duration
String title,
String description
) {} ) {}

View File

@@ -1,7 +0,0 @@
package com.ddf.vodsystem.controllers.dto;
import jakarta.validation.constraints.NotBlank;
public record MarkerCreateRequest(
@NotBlank String message
) {}

View File

@@ -1,11 +0,0 @@
package com.ddf.vodsystem.controllers.dto;
import java.time.Instant;
public record MarkerResponse(
Long id,
Long userId,
Long streamId,
String message,
Instant timestamp
) {}

View File

@@ -1,9 +0,0 @@
package com.ddf.vodsystem.controllers.dto;
import jakarta.validation.constraints.NotNull;
public record SaveSectionByMarkerRequest(
@NotNull Long startMarkerId,
@NotNull Long endMarkerId,
String title,
String description) {}

View File

@@ -5,6 +5,4 @@ import jakarta.validation.constraints.NotNull;
public record SaveSectionRequest( public record SaveSectionRequest(
@NotNull Instant startTime, @NotNull Instant startTime,
@NotNull Instant endTime, @NotNull Instant endTime) {}
String title,
String description) {}

View File

@@ -1,12 +0,0 @@
package com.ddf.vodsystem.controllers.dto;
import java.time.Instant;
public record VodResponse(
Long id,
Long userId,
String title,
String description,
Float duration,
Instant createdAt
) {}

View File

@@ -1,6 +0,0 @@
package com.ddf.vodsystem.controllers.dto;
public record VodUpdateRequest(
String title,
String description
) {}

View File

@@ -27,5 +27,5 @@ public class ClipOptions {
private Integer height; private Integer height;
@Positive @Positive
private Long fileSize; private Float fileSize;
} }

View File

@@ -43,7 +43,7 @@ public class Clip {
private Float duration; private Float duration;
@Column(name = "file_size", nullable = false) @Column(name = "file_size", nullable = false)
private Long fileSize; private Float fileSize;
@Column(name = "video_path", nullable = false, length = 255) @Column(name = "video_path", nullable = false, length = 255)
private String videoPath; private String videoPath;

View File

@@ -1,33 +0,0 @@
package com.ddf.vodsystem.entities;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import lombok.Data;
import java.time.Instant;
@Entity
@Table(name = "markers")
@Data
public class Marker {
@Id
@Column(name = "id", nullable = false, unique = true)
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JsonIgnore
@JoinColumn(name = "user_id", nullable = false)
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@JsonIgnore
@JoinColumn(name = "stream_id", nullable = false)
private Stream stream;
@Column(name = "message", length = 255)
private String message;
@Column(name = "timestamp")
private Instant timestamp;
}

View File

@@ -1,52 +0,0 @@
package com.ddf.vodsystem.entities;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import lombok.Data;
import java.time.Instant;
@Entity
@Table(name = "vods")
@Data
public class Vod {
@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 = "title", nullable = false, length = 255)
private String title;
@Column(name = "description")
private String description;
@Column(name = "created_at")
private Instant createdAt;
@Column(name = "width", nullable = false)
private Integer width;
@Column(name = "height", nullable = false)
private Integer height;
@Column(name = "fps", nullable = false)
private Float fps;
@Column(name = "duration", nullable = false)
private Float duration;
@Column(name = "file_size", nullable = false)
private Long fileSize;
@Column(name = "video_path", nullable = false, length = 255)
private String videoPath;
@Column(name = "thumbnail_path", nullable = false, length = 255)
private String thumbnailPath;
}

View File

@@ -1,18 +0,0 @@
package com.ddf.vodsystem.exceptions;
public class ClipMediaUnavailable extends RuntimeException {
private final Long clipId;
public ClipMediaUnavailable(Long clipId, String message) {
this(clipId, message, null);
}
public ClipMediaUnavailable(Long clipId, String message, Throwable cause) {
super(message, cause);
this.clipId = clipId;
}
public Long getClipId() {
return clipId;
}
}

View File

@@ -1,7 +0,0 @@
package com.ddf.vodsystem.exceptions;
public class MarkerNotFound extends RuntimeException {
public MarkerNotFound(String message) {
super(message);
}
}

View File

@@ -1,7 +0,0 @@
package com.ddf.vodsystem.exceptions;
public class NotStreaming extends RuntimeException {
public NotStreaming(String message) {
super(message);
}
}

View File

@@ -1,18 +0,0 @@
package com.ddf.vodsystem.exceptions;
public class VodMediaUnavailable extends RuntimeException {
private final Long vodId;
public VodMediaUnavailable(Long vodId, String message) {
this(vodId, message, null);
}
public VodMediaUnavailable(Long vodId, String message, Throwable cause) {
super(message, cause);
this.vodId = vodId;
}
public Long getVodId() {
return vodId;
}
}

View File

@@ -1,7 +0,0 @@
package com.ddf.vodsystem.exceptions;
public class VodNotFound extends RuntimeException {
public VodNotFound(String message) {
super(message);
}
}

View File

@@ -1,19 +0,0 @@
package com.ddf.vodsystem.repositories;
import com.ddf.vodsystem.entities.Marker;
import com.ddf.vodsystem.entities.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.time.Instant;
import java.util.List;
@Repository
public interface MarkerRepository extends JpaRepository<Marker, Long> {
@Query("SELECT m FROM Marker m WHERE m.user = ?1")
List<Marker> findByUser(User user);
@Query("SELECT m FROM Marker m WHERE m.timestamp <= ?1")
List<Marker> findAllBefore(Instant time);
}

View File

@@ -1,16 +0,0 @@
package com.ddf.vodsystem.repositories;
import com.ddf.vodsystem.entities.User;
import com.ddf.vodsystem.entities.Vod;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface VodRepository extends JpaRepository<Vod, Long>
{
@Query("SELECT v FROM Vod v WHERE v.user = ?1")
List<Vod> findByUser(User user);
}

View File

@@ -46,10 +46,10 @@ public class ClipService {
} }
/** /**
* Returns all clips belonging to the currently authenticated user. * Retrieves all clips associated with the currently logged-in user.
* *
* @return list of clips owned by the current user, in repository order * @return a list of clips belonging to the authenticated user.
* @throws NotAuthenticated if no user session is present * @throws NotAuthenticated if the user is not authenticated.
*/ */
public List<Clip> getClipsByUser() { public List<Clip> getClipsByUser() {
Optional<User> user = userService.getLoggedInUser(); Optional<User> user = userService.getLoggedInUser();
@@ -62,14 +62,13 @@ public class ClipService {
} }
/** /**
* Returns the clip with the given ID, verifying ownership by the current user. * Retrieves a clip by its ID, ensuring the user is authenticated to access it.
* *
* @param id the ID of the clip to retrieve * @param id the ID of the clip to retrieve.
* @return the matching {@link Clip} entity * @return an Optional containing the Clip if found and accessible, or empty if not found.
* @throws ClipNotFound if no clip with {@code id} exists * @throws NotAuthenticated if the user is not authorized to access the clip.
* @throws NotAuthenticated if the current user does not own the clip
*/ */
public Clip getClipById(Long id) { public Optional<Clip> getClipById(Long id) {
Optional<Clip> clip = clipRepository.findById(id); Optional<Clip> clip = clipRepository.findById(id);
if (clip.isEmpty()) { if (clip.isEmpty()) {
@@ -80,22 +79,21 @@ public class ClipService {
throw new NotAuthenticated("You are not authorized to access clip: " + id); throw new NotAuthenticated("You are not authorized to access clip: " + id);
} }
return clip.get(); return clip;
} }
/**
* Applies a partial update to an existing clip's metadata.
* <p>
* Only non-null fields in {@code newFields} are written; null fields are left unchanged.
*
* @param id the ID of the clip to update
* @param newFields fields to overwrite; any null field is ignored
* @return the updated clip as persisted in the database
* @throws ClipNotFound if no clip with {@code id} exists
* @throws NotAuthenticated if the current user does not own the clip
*/
public Clip updateClip(Long id, ClipUpdateRequest newFields) { public Clip updateClip(Long id, ClipUpdateRequest newFields) {
Clip clip = getClipById(id); Optional<Clip> possibleClip = clipRepository.findById(id);
if (possibleClip.isEmpty()) {
throw new ClipNotFound("Clip with id " + id + " not found.");
}
Clip clip = possibleClip.get();
if (!isAuthenticatedForClip(clip)) {
throw new NotAuthenticated("You are not authorized to access clip: " + id);
}
if (newFields.title() != null) { if (newFields.title() != null) {
clip.setTitle(newFields.title()); clip.setTitle(newFields.title());
@@ -109,26 +107,36 @@ public class ClipService {
} }
/** /**
* Deletes a clip and its associated files from disk. * Deletes a clip by its ID, ensuring the user is authenticated to perform the deletion.
* *
* @param id the ID of the clip to delete * @param id the ID of the clip to delete.
* @throws ClipNotFound if no clip with {@code id} exists * @return true if the clip was successfully deleted, false if it was not found.
* @throws NotAuthenticated if the current user does not own the clip * @throws NotAuthenticated if the user is not authorized to delete the clip.
*/ */
public void deleteClip(Long id) { public boolean deleteClip(Long id) {
Clip clip = getClipById(id); Optional<Clip> possibleClip = getClipById(id);
if (possibleClip.isEmpty()) {
logger.warn("Clip with ID {} not found for deletion", id);
return false;
}
Clip clip = possibleClip.get();
if (!isAuthenticatedForClip(clip)) {
throw new NotAuthenticated("You are not authorized to delete this clip");
}
deleteClipFiles(clip); deleteClipFiles(clip);
clipRepository.delete(clip); clipRepository.delete(clip);
logger.info("Clip with ID {} deleted successfully", id); logger.info("Clip with ID {} deleted successfully", id);
return true;
} }
/** /**
* Returns whether the current user owns the given clip. * Checks if the currently logged-in user is authenticated to access the specified clip.
* *
* @param clip the clip to check ownership of * @param clip the clip to check access for.
* @return {@code true} if the current user's ID matches the clip's owner; {@code false} if * @return true if the user is authenticated for the clip, false otherwise.
* {@code clip} is null or no user session exists
*/ */
public boolean isAuthenticatedForClip(Clip clip) { public boolean isAuthenticatedForClip(Clip clip) {
Optional<User> user = userService.getLoggedInUser(); Optional<User> user = userService.getLoggedInUser();
@@ -138,68 +146,79 @@ public class ClipService {
return user.get().getId().equals(clip.getUser().getId()); return user.get().getId().equals(clip.getUser().getId());
} }
/**
* Returns a streamable resource for the clip's video file.
*
* @param id the ID of the clip to download
* @return a {@link Resource} pointing to the clip's video file on disk
* @throws ClipNotFound if no clip with {@code id} exists, or the video file is missing on disk
* @throws NotAuthenticated if the current user does not own the clip
*/
public Resource downloadClip(Long id) { public Resource downloadClip(Long id) {
Clip clip = getClipById(id); Optional<Clip> possibleClip = getClipById(id);
if (possibleClip.isEmpty()) {
throw new ClipNotFound("Clip " + id + " doesn't exist");
}
Clip clip = possibleClip.get();
if (!isAuthenticatedForClip(clip)) {
throw new NotAuthenticated("Not authenticated for this clip");
}
String path = clip.getVideoPath(); String path = clip.getVideoPath();
Path file = directoryService.resolvePath(path); Path file = directoryService.resolvePath(path);
if (!Files.exists(file)) { if (!Files.exists(file)) {
throw new ClipNotFound("Clip file not found"); throw new JobNotFound("Clip file not found");
}
if (!Files.isReadable(file)) {
throw new ClipMediaUnavailable(id, "Clip file is not readable");
} }
return new FileSystemResource(file); return new FileSystemResource(file);
} }
/**
* Returns a streamable resource for the clip's thumbnail image.
*
* @param id the ID of the clip whose thumbnail to download
* @return a {@link Resource} pointing to the thumbnail file on disk
* @throws ClipNotFound if no clip with {@code id} exists, or the thumbnail file is missing on disk
* @throws NotAuthenticated if the current user does not own the clip
*/
public Resource downloadThumbnail(Long id) { public Resource downloadThumbnail(Long id) {
Clip clip = getClipById(id); Optional<Clip> possibleClip = getClipById(id);
if (possibleClip.isEmpty()) {
throw new ClipNotFound("Clip " + id + " doesn't exist");
}
Clip clip = possibleClip.get();
if (!isAuthenticatedForClip(clip)) {
throw new NotAuthenticated("Not authenticated for this clip thumbnail");
}
String path = clip.getThumbnailPath(); String path = clip.getThumbnailPath();
Path file = directoryService.resolvePath(path); Path file = directoryService.resolvePath(path);
if (!Files.exists(file)) { if (!Files.exists(file)) {
throw new ClipNotFound("Thumbnail file not found"); throw new JobNotFound("Thumbnail file not found");
} }
return new FileSystemResource(file); return new FileSystemResource(file);
} }
/** /**
* Copies a processed clip file into permanent storage, generates its thumbnail, and saves * Persists a clip to the database
* the clip record to the database. * @param options ClipOptions object of the clip metadata to save to the database. All fields required except for title, description
* <p> * @param user User to save the clip to
* Thumbnail generation failure is non-fatal — the error is logged and the clip is saved * @param videoPath Path of the clip
* without a thumbnail rather than aborting. * @param thumbnailPath Path of the thumbnail
* * @return Clip object saved to the database
* @param title title to assign to the clip
* @param description description to assign to the clip
* @param user owner of the clip
* @param clipFile path to the processed video file (typically in the temp output directory)
* @param fileName filename used for both the permanent video file and the thumbnail
* (thumbnail is stored as {@code fileName + ".png"})
* @throws StorageException if copying the file to the clips directory fails
* @throws FFMPEGException if reading video metadata from the copied file fails
*/ */
public Clip saveClip(ClipOptions options,
User user,
Path videoPath,
Path thumbnailPath) {
Clip clip = new Clip();
clip.setUser(user);
clip.setTitle(options.getTitle() != null ? options.getTitle() : "Untitled Clip");
clip.setDescription(options.getDescription() != null ? options.getDescription() : "");
clip.setCreatedAt(Instant.now());
clip.setWidth(options.getWidth());
clip.setHeight(options.getHeight());
clip.setFps(options.getFps());
clip.setDuration(options.getDuration() - options.getStartPoint());
clip.setFileSize(options.getFileSize());
clip.setVideoPath(directoryService.relativisePath(videoPath.toAbsolutePath()).toString());
clip.setThumbnailPath(directoryService.relativisePath(thumbnailPath.toAbsolutePath()).toString());
return clipRepository.save(clip);
}
public void persistClip(String title, public void persistClip(String title,
String description, String description,
User user, User user,
@@ -226,11 +245,14 @@ public class ClipService {
} }
// Thumbnail generation can fail with error propagation // Thumbnail generation can fail with error propagation
thumbnailService.createThumbnail(newClipFile, thumbnailFile, 0.0f) try {
.exceptionally(ex -> { thumbnailService.createThumbnail(newClipFile, thumbnailFile, 0.0f);
logger.error("Thumbnail job for user {} failed: {}", user.getId(), ex.toString()); } catch (InterruptedException e) {
return null; logger.error("Thumbnail generation interrupted for user: {}", user.getId(), e);
}); Thread.currentThread().interrupt();
} catch (IOException e) {
logger.error("Error generating thumbnail for user: {}", user.getId(), e);
}
clipMetadata.setTitle(title); clipMetadata.setTitle(title);
clipMetadata.setDescription(description); clipMetadata.setDescription(description);
@@ -239,25 +261,6 @@ public class ClipService {
logger.info("Clip created successfully with ID: {}", clip.getId()); logger.info("Clip created successfully with ID: {}", clip.getId());
} }
private Clip saveClip(ClipOptions metadata,
User user,
Path videoPath,
Path thumbnailPath) {
Clip clip = new Clip();
clip.setUser(user);
clip.setTitle(metadata.getTitle() != null ? metadata.getTitle() : "Untitled Clip");
clip.setDescription(metadata.getDescription() != null ? metadata.getDescription() : "");
clip.setCreatedAt(Instant.now());
clip.setWidth(metadata.getWidth());
clip.setHeight(metadata.getHeight());
clip.setFps(metadata.getFps());
clip.setDuration(metadata.getDuration() - metadata.getStartPoint());
clip.setFileSize(metadata.getFileSize());
clip.setVideoPath(directoryService.relativisePath(videoPath.toAbsolutePath()).toString());
clip.setThumbnailPath(directoryService.relativisePath(thumbnailPath.toAbsolutePath()).toString());
return clipRepository.save(clip);
}
private void deleteClipFiles(Clip clip) { private void deleteClipFiles(Clip clip) {
File clipFile = new File(clip.getVideoPath()); File clipFile = new File(clip.getVideoPath());
File thumbnailFile = new File(clip.getThumbnailPath()); File thumbnailFile = new File(clip.getThumbnailPath());

View File

@@ -10,7 +10,6 @@ import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List;
import java.util.stream.Stream; import java.util.stream.Stream;
import java.nio.file.StandardCopyOption; import java.nio.file.StandardCopyOption;
@@ -130,29 +129,11 @@ public class DirectoryService {
} }
} }
private void checkDirPermissions() {
List<Path> dirs = List.of(
Path.of(tempInputsDir),
Path.of(tempOutputsDir),
Path.of(usersDir)
);
List<Path> bad = dirs.stream()
.filter(p -> !Files.isDirectory(p) || !Files.isReadable(p) || !Files.isWritable(p))
.toList();
if (!bad.isEmpty()) {
throw new IllegalStateException("Unusable directories: " + bad);
}
}
@PostConstruct @PostConstruct
public void createDirectoriesIfNotExist() throws IOException { public void createDirectoriesIfNotExist() throws IOException {
Files.createDirectories(Path.of(tempInputsDir)); Files.createDirectories(Path.of(tempInputsDir));
Files.createDirectories(Path.of(tempOutputsDir)); Files.createDirectories(Path.of(tempOutputsDir));
Files.createDirectories(Path.of(usersDir)); Files.createDirectories(Path.of(usersDir));
checkDirPermissions();
} }
@Scheduled(fixedRate = TEMP_DIR_CLEANUP_RATE) @Scheduled(fixedRate = TEMP_DIR_CLEANUP_RATE)

Some files were not shown because too many files have changed in this diff Show More