Compare commits
1 Commits
09cc841dfd
...
prod
| Author | SHA1 | Date | |
|---|---|---|---|
| d6c703457c |
@@ -16,5 +16,3 @@ POSTGRES_DB=vodSystem
|
|||||||
|
|
||||||
FRONTEND_URL=
|
FRONTEND_URL=
|
||||||
BACKEND_URL=
|
BACKEND_URL=
|
||||||
|
|
||||||
STREAM_RING_LENGTH_SECOND=28800
|
|
||||||
@@ -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
1
.gitignore
vendored
@@ -44,3 +44,4 @@ build/
|
|||||||
frontend/.flowbite-react
|
frontend/.flowbite-react
|
||||||
|
|
||||||
generated
|
generated
|
||||||
|
.mvn
|
||||||
|
|||||||
3
.mvn/wrapper/maven-wrapper.properties
vendored
3
.mvn/wrapper/maven-wrapper.properties
vendored
@@ -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
144
README.md
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Get all markers
|
|
||||||
type: http
|
|
||||||
seq: 2
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{base_url}}/markers
|
|
||||||
body: none
|
|
||||||
auth: inherit
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Markers
|
|
||||||
seq: 6
|
|
||||||
}
|
|
||||||
|
|
||||||
auth {
|
|
||||||
mode: inherit
|
|
||||||
}
|
|
||||||
@@ -12,9 +12,7 @@ post {
|
|||||||
|
|
||||||
body:json {
|
body:json {
|
||||||
{
|
{
|
||||||
"duration": 60.0,
|
"duration": 60.0
|
||||||
"title": "Hello",
|
|
||||||
"description": "my clip"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Downloads
|
|
||||||
seq: 5
|
|
||||||
}
|
|
||||||
|
|
||||||
auth {
|
|
||||||
mode: inherit
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Get all vods
|
|
||||||
type: http
|
|
||||||
seq: 1
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{base_url}}/vods
|
|
||||||
body: none
|
|
||||||
auth: inherit
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: VoDs
|
|
||||||
seq: 2
|
|
||||||
}
|
|
||||||
|
|
||||||
auth {
|
|
||||||
mode: inherit
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
vars {
|
|
||||||
base_url: http://http://server.tail3cd897.ts.net:8087/api/v1
|
|
||||||
uuid:
|
|
||||||
}
|
|
||||||
vars:secret [
|
|
||||||
google_client_id,
|
|
||||||
google_secret
|
|
||||||
]
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<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" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Vite + React + TS</title>
|
<title>Vite + React + TS</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 MiB |
@@ -1,4 +1,4 @@
|
|||||||
// App.jsx
|
// App.jsx
|
||||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||||
import MainLayout from './layouts/MainLayout';
|
import MainLayout from './layouts/MainLayout';
|
||||||
import ClipUpload from './pages/ClipUpload';
|
import ClipUpload from './pages/ClipUpload';
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import SidebarButton from "./buttons/SidebarButton.tsx";
|
import SidebarButton from "./buttons/SidebarButton.tsx";
|
||||||
import { Plus, Film, Home, User as UserIcon, Video, Radio } from 'lucide-react';
|
import { Plus, Film, Home, User as UserIcon } from 'lucide-react';
|
||||||
import type {User} from "../utils/types.ts";
|
import type {User} from "../utils/types.ts";
|
||||||
|
|
||||||
type props = {
|
type props = {
|
||||||
@@ -30,9 +30,7 @@ const Sidebar = ({user, className}: props) => {
|
|||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<SidebarButton url="/stream" logo={<Radio size={18}/>} label="Stream" />
|
|
||||||
<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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Menu, X } from 'lucide-react';
|
import { Menu, X } from 'lucide-react';
|
||||||
import { login, logout } from "../utils/api/users.ts";
|
import { login, logout } from "../utils/endpoints.ts";
|
||||||
import { Dropdown, DropdownItem } from "./Dropdown.tsx";
|
import { Dropdown, DropdownItem } from "./Dropdown.tsx";
|
||||||
import { GoogleLogin } from '@react-oauth/google';
|
import { GoogleLogin } from '@react-oauth/google';
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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!)}
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import Topbar from '../components/Topbar'
|
|||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import type {User} from "../utils/types";
|
import type {User} from "../utils/types";
|
||||||
import { getUser } from "../utils/api/users";
|
import { getUser, getStreamStatus } from "../utils/endpoints";
|
||||||
import { getStreamStatus } from "../utils/api/stream";
|
|
||||||
|
|
||||||
const MainLayout = () => {
|
const MainLayout = () => {
|
||||||
const [sidebarToggled, setSidebarToggled] = useState(false);
|
const [sidebarToggled, setSidebarToggled] = useState(false);
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ 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 Box from "../components/Box.tsx";
|
import Box from "../components/Box.tsx";
|
||||||
import MetadataBox from "../components/video/MetadataBox.tsx";
|
import MetadataBox from "../components/video/MetadataBox.tsx";
|
||||||
@@ -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);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -175,7 +166,6 @@ const ClipEdit = () => {
|
|||||||
handleDownload={handleDownload}
|
handleDownload={handleDownload}
|
||||||
downloadable={downloadable}
|
downloadable={downloadable}
|
||||||
progress={progress}
|
progress={progress}
|
||||||
uploading={uploading}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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 Box from "../components/Box.tsx";
|
import Box from "../components/Box.tsx";
|
||||||
import BlueButton from "../components/buttons/BlueButton.tsx";
|
import BlueButton from "../components/buttons/BlueButton.tsx";
|
||||||
|
|
||||||
|
|||||||
@@ -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 Box from "../components/Box.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>
|
|
||||||
) : (
|
|
||||||
<Box 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>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EditVod;
|
|
||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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 Box from "../components/Box.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";
|
||||||
|
|
||||||
|
|||||||
@@ -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 Box from "../components/Box.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 */}
|
|
||||||
<Box 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" />
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{isStreaming && (
|
|
||||||
<>
|
|
||||||
{/* Quick mark toolbar */}
|
|
||||||
<Box 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>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Forefront actions */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5 items-stretch">
|
|
||||||
<Box 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>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box 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>
|
|
||||||
</Box>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Box 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>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default StreamControl;
|
|
||||||
@@ -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 { AuthError, getClipById, getVideoBlob } from "../utils/endpoints.ts";
|
||||||
import Box from "../components/Box.tsx";
|
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,7 +310,7 @@ 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...
|
||||||
@@ -340,11 +318,11 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Box 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>
|
||||||
</Box>
|
</Box>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MediaPlayer;
|
export default VideoPlayer;
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
172
frontend/src/utils/endpoints.ts
Normal file
172
frontend/src/utils/endpoints.ts
Normal 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,
|
||||||
|
};
|
||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
@@ -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
50
mvnw
vendored
@@ -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
56
mvnw.cmd
vendored
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
5
pom.xml
5
pom.xml
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
) {}
|
) {}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package com.ddf.vodsystem.controllers.dto;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
|
||||||
|
|
||||||
public record MarkerCreateRequest(
|
|
||||||
@NotBlank String message
|
|
||||||
) {}
|
|
||||||
@@ -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
|
|
||||||
) {}
|
|
||||||
@@ -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) {}
|
|
||||||
@@ -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) {}
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
) {}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package com.ddf.vodsystem.controllers.dto;
|
|
||||||
|
|
||||||
public record VodUpdateRequest(
|
|
||||||
String title,
|
|
||||||
String description
|
|
||||||
) {}
|
|
||||||
@@ -27,5 +27,5 @@ public class ClipOptions {
|
|||||||
private Integer height;
|
private Integer height;
|
||||||
|
|
||||||
@Positive
|
@Positive
|
||||||
private Long fileSize;
|
private Float fileSize;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package com.ddf.vodsystem.exceptions;
|
|
||||||
|
|
||||||
public class MarkerNotFound extends RuntimeException {
|
|
||||||
public MarkerNotFound(String message) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package com.ddf.vodsystem.exceptions;
|
|
||||||
|
|
||||||
public class NotStreaming extends RuntimeException {
|
|
||||||
public NotStreaming(String message) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package com.ddf.vodsystem.exceptions;
|
|
||||||
|
|
||||||
public class VodNotFound extends RuntimeException {
|
|
||||||
public VodNotFound(String message) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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());
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
package com.ddf.vodsystem.services;
|
|
||||||
|
|
||||||
import com.ddf.vodsystem.entities.Marker;
|
|
||||||
import com.ddf.vodsystem.entities.Stream;
|
|
||||||
import com.ddf.vodsystem.entities.User;
|
|
||||||
import com.ddf.vodsystem.exceptions.MarkerNotFound;
|
|
||||||
import com.ddf.vodsystem.exceptions.NotAuthenticated;
|
|
||||||
import com.ddf.vodsystem.exceptions.NotStreaming;
|
|
||||||
import com.ddf.vodsystem.repositories.MarkerRepository;
|
|
||||||
import jakarta.transaction.Transactional;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
public class MarkerService {
|
|
||||||
|
|
||||||
private final UserService userService;
|
|
||||||
private final StreamService streamService;
|
|
||||||
private final MarkerRepository markerRepository;
|
|
||||||
|
|
||||||
@Value("${stream.max-length}")
|
|
||||||
private Long maxStreamLength;
|
|
||||||
|
|
||||||
public MarkerService(UserService userService, StreamService streamService, MarkerRepository markerRepository) {
|
|
||||||
this.userService = userService;
|
|
||||||
this.streamService = streamService;
|
|
||||||
this.markerRepository = markerRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Marker getMarkerById(Long id) {
|
|
||||||
Optional<Marker> marker = markerRepository.findById(id);
|
|
||||||
|
|
||||||
if (marker.isEmpty()) {
|
|
||||||
throw new MarkerNotFound("Marker does not exist");
|
|
||||||
}
|
|
||||||
|
|
||||||
Optional<User> user = userService.getLoggedInUser();
|
|
||||||
if (user.isEmpty() || !user.get().equals(marker.get().getUser())) {
|
|
||||||
throw new NotAuthenticated("User not authenticated for this marker");
|
|
||||||
}
|
|
||||||
|
|
||||||
return marker.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<Marker> getUserMarkers() {
|
|
||||||
Optional<User> user = userService.getLoggedInUser();
|
|
||||||
|
|
||||||
if (user.isEmpty()) {
|
|
||||||
throw new NotAuthenticated("Must be logged in to get markers");
|
|
||||||
}
|
|
||||||
|
|
||||||
return markerRepository.findByUser(user.get());
|
|
||||||
}
|
|
||||||
|
|
||||||
public Marker create(String message) {
|
|
||||||
Optional<Stream> stream = streamService.getActiveStream();
|
|
||||||
|
|
||||||
if (stream.isEmpty()) {
|
|
||||||
throw new NotStreaming("User must be streaming to mark");
|
|
||||||
}
|
|
||||||
|
|
||||||
Marker marker = new Marker();
|
|
||||||
marker.setStream(stream.get());
|
|
||||||
marker.setUser(stream.get().getUser());
|
|
||||||
marker.setMessage(message);
|
|
||||||
marker.setTimestamp(Instant.now());
|
|
||||||
return markerRepository.saveAndFlush(marker);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void deleteMarker(Long id) {
|
|
||||||
Marker marker = getMarkerById(id);
|
|
||||||
markerRepository.delete(marker);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Scheduled(fixedDelay = 360_000)
|
|
||||||
@Transactional
|
|
||||||
public void deleteOldMarkers() {
|
|
||||||
Instant cutoff = Instant.now().minusSeconds(maxStreamLength);
|
|
||||||
List<Marker> stale = markerRepository.findAllBefore(cutoff);
|
|
||||||
markerRepository.deleteAllInBatch(stale);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,9 +3,7 @@ package com.ddf.vodsystem.services;
|
|||||||
import com.ddf.vodsystem.dto.ClipOptions;
|
import com.ddf.vodsystem.dto.ClipOptions;
|
||||||
import com.ddf.vodsystem.dto.Job;
|
import com.ddf.vodsystem.dto.Job;
|
||||||
import com.ddf.vodsystem.dto.JobState;
|
import com.ddf.vodsystem.dto.JobState;
|
||||||
import com.ddf.vodsystem.entities.Marker;
|
|
||||||
import com.ddf.vodsystem.entities.User;
|
import com.ddf.vodsystem.entities.User;
|
||||||
import com.ddf.vodsystem.exceptions.MarkerNotFound;
|
|
||||||
import com.ddf.vodsystem.exceptions.NotAuthenticated;
|
import com.ddf.vodsystem.exceptions.NotAuthenticated;
|
||||||
import com.ddf.vodsystem.services.media.CompressionService;
|
import com.ddf.vodsystem.services.media.CompressionService;
|
||||||
import com.ddf.vodsystem.services.media.StreamActionsService;
|
import com.ddf.vodsystem.services.media.StreamActionsService;
|
||||||
@@ -13,10 +11,12 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class MediaService {
|
public class MediaService {
|
||||||
@@ -28,39 +28,23 @@ public class MediaService {
|
|||||||
private final ClipService clipService;
|
private final ClipService clipService;
|
||||||
|
|
||||||
private static final float CLIP_MAX_LENGTH = 180;
|
private static final float CLIP_MAX_LENGTH = 180;
|
||||||
|
private static final int HLS_SEGMENT_LENGTH = 3;
|
||||||
private final StreamActionsService streamActionsService;
|
private final StreamActionsService streamActionsService;
|
||||||
private final VodService vodService;
|
|
||||||
private final MarkerService markerService;
|
|
||||||
|
|
||||||
public MediaService(JobRegistryService jobRegistryService,
|
public MediaService(JobRegistryService jobRegistryService,
|
||||||
DirectoryService directoryService,
|
DirectoryService directoryService,
|
||||||
CompressionService compressionService,
|
CompressionService compressionService,
|
||||||
UserService userService,
|
UserService userService,
|
||||||
ClipService clipService, StreamActionsService streamActionsService, VodService vodService, MarkerService markerService) {
|
ClipService clipService, StreamActionsService streamActionsService) {
|
||||||
this.jobRegistryService = jobRegistryService;
|
this.jobRegistryService = jobRegistryService;
|
||||||
this.directoryService = directoryService;
|
this.directoryService = directoryService;
|
||||||
this.compressionService = compressionService;
|
this.compressionService = compressionService;
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
this.clipService = clipService;
|
this.clipService = clipService;
|
||||||
this.streamActionsService = streamActionsService;
|
this.streamActionsService = streamActionsService;
|
||||||
this.vodService = vodService;
|
|
||||||
this.markerService = markerService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public Job compress(MultipartFile file, ClipOptions clipOptions) throws IOException, InterruptedException {
|
||||||
* Submits an asynchronous compression job for the uploaded video file.
|
|
||||||
* <p>
|
|
||||||
* Saves the upload to a temporary input path, then runs the compression pipeline
|
|
||||||
* in the background according to {@code clipOptions}. If a user session exists at
|
|
||||||
* call time, the output is also persisted as a clip when the job succeeds.
|
|
||||||
*
|
|
||||||
* @param file the uploaded video file to compress
|
|
||||||
* @param clipOptions trim window, resolution, fps, and metadata for the output clip
|
|
||||||
* @return the {@link Job} tracking the compression; state transitions to
|
|
||||||
* {@link JobState#SUCCEEDED} or {@link JobState#FAILED} asynchronously
|
|
||||||
* @throws IOException if saving the upload to the temporary directory fails
|
|
||||||
*/
|
|
||||||
public Job compress(MultipartFile file, ClipOptions clipOptions) throws IOException {
|
|
||||||
Job job = jobRegistryService.generateJob();
|
Job job = jobRegistryService.generateJob();
|
||||||
|
|
||||||
Optional<User> user = userService.getLoggedInUser();
|
Optional<User> user = userService.getLoggedInUser();
|
||||||
@@ -93,7 +77,7 @@ public class MediaService {
|
|||||||
);
|
);
|
||||||
}).exceptionally(ex -> {
|
}).exceptionally(ex -> {
|
||||||
job.setState(JobState.FAILED);
|
job.setState(JobState.FAILED);
|
||||||
job.setErrorOutput("Compression job failed");
|
job.setErrorOutput(ex.getMessage());
|
||||||
logger.error("Compression job with UUID {} failed due to: {}", job.getUuid(), ex.getMessage());
|
logger.error("Compression job with UUID {} failed due to: {}", job.getUuid(), ex.getMessage());
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
@@ -102,29 +86,20 @@ public class MediaService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saves a section of the currently authenticated user's live stream between two absolute timestamps.
|
* Saves a clip of the currently authenticated user's stream between two points in time.
|
||||||
* <p>
|
* <p>
|
||||||
* Locates the recorded {@code .ts} HLS segments that overlap {@code [startTime, endTime)}, computes
|
* Locates the recorded {@code .ts} segments overlapping the requested window, computes the
|
||||||
* the trim offset into the first partially-overlapping segment, then delegates extraction and muxing
|
* trim offset into the first (partially-overlapping) segment and the total clip duration,
|
||||||
* to {@link StreamActionsService#saveSection}. On success, the output is persisted as a VoD via
|
* then delegates the actual extraction to {@link StreamActionsService#saveSection}.
|
||||||
* {@link VodService#persist}.
|
|
||||||
*
|
*
|
||||||
* @param startTime inclusive start of the section to save; must be before {@code endTime}
|
* @param startTime the inclusive start of the section to save; must be before {@code endTime}
|
||||||
* @param endTime exclusive end of the section to save
|
* @param endTime the exclusive end of the section to save
|
||||||
* @param title title for the saved VoD; defaults to the current timestamp if {@code null}
|
|
||||||
* @param description description for the saved VoD; defaults to an empty string if {@code null}
|
|
||||||
* @return the {@link Job} tracking the save; state transitions to
|
|
||||||
* {@link JobState#SUCCEEDED} or {@link JobState#FAILED} asynchronously
|
|
||||||
* @throws IllegalArgumentException if {@code startTime} is not before {@code endTime},
|
* @throws IllegalArgumentException if {@code startTime} is not before {@code endTime},
|
||||||
* or if no stream segments exist in the given range
|
* or if no stream segments exist in the given range
|
||||||
* @throws NotAuthenticated if no user is currently authenticated
|
* @throws NotAuthenticated if no user is currently authenticated
|
||||||
* @throws IOException if reading the stream directory or its segments fails
|
* @throws IOException if reading the stream directory or its segments fails
|
||||||
*/
|
*/
|
||||||
public Job saveSection(
|
public Job saveSection(Instant startTime, Instant endTime) throws IOException {
|
||||||
Instant startTime,
|
|
||||||
Instant endTime,
|
|
||||||
String title,
|
|
||||||
String description) throws IOException {
|
|
||||||
if (startTime.isAfter(endTime)) {
|
if (startTime.isAfter(endTime)) {
|
||||||
throw new IllegalArgumentException("Start time must be before end time");
|
throw new IllegalArgumentException("Start time must be before end time");
|
||||||
}
|
}
|
||||||
@@ -133,127 +108,120 @@ public class MediaService {
|
|||||||
.orElseThrow(() -> new NotAuthenticated("User is not authenticated"));
|
.orElseThrow(() -> new NotAuthenticated("User is not authenticated"));
|
||||||
|
|
||||||
Job job = jobRegistryService.generateJob();
|
Job job = jobRegistryService.generateJob();
|
||||||
SegmentContext ctx = resolveSegments(user, startTime, endTime);
|
Path streamDirectory = directoryService.getStreamDir(user.getStreamKey());
|
||||||
|
List<Path> fileSegments = getSegmentsInRange(streamDirectory, startTime, endTime);
|
||||||
|
|
||||||
|
if (fileSegments.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("No stream segments found in the given time range");
|
||||||
|
}
|
||||||
|
|
||||||
|
long firstSegmentMs = parseTimestampMs(fileSegments.getFirst());
|
||||||
|
float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstSegmentMs) / 1000f);
|
||||||
float duration = (endTime.toEpochMilli() - startTime.toEpochMilli()) / 1000f;
|
float duration = (endTime.toEpochMilli() - startTime.toEpochMilli()) / 1000f;
|
||||||
|
|
||||||
|
job.setState(JobState.PROCESSING);
|
||||||
Path outputFile = directoryService.getVodsDir(user.getId()).resolve(UUID.randomUUID() + ".mp4");
|
Path outputFile = directoryService.getVodsDir(user.getId()).resolve(UUID.randomUUID() + ".mp4");
|
||||||
|
streamActionsService.saveSection(
|
||||||
return dispatchSaveJob(job, ctx, duration, outputFile, () ->
|
fileSegments,
|
||||||
vodService.persist(
|
trimOffset,
|
||||||
title == null ? Instant.now().toString() : title,
|
duration,
|
||||||
description == null ? "" : description,
|
|
||||||
user,
|
|
||||||
outputFile,
|
outputFile,
|
||||||
outputFile.getFileName().toString()
|
job.getProgressTracker()
|
||||||
)
|
).thenRun(() -> {
|
||||||
);
|
job.setDownload(outputFile);
|
||||||
|
job.setState(JobState.SUCCEEDED);
|
||||||
|
logger.info("Stream save of job {} succeeded", job.getUuid());
|
||||||
|
}).exceptionally(ex -> {
|
||||||
|
job.setState(JobState.FAILED);
|
||||||
|
job.setErrorOutput(ex.getMessage());
|
||||||
|
logger.error("Stream section save job with UUID {} failed due to: {}", job.getUuid(), ex.getMessage());
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return job;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saves a section of the currently authenticated user's live stream between two markers.
|
* Saves a clip of the last {@code duration} seconds of the currently authenticated user's stream.
|
||||||
* <p>
|
* <p>
|
||||||
* Resolves {@code startMarkerId} and {@code endMarkerId} to their marker timestamps and
|
* Computes a time window ending at the current instant and beginning {@code duration} seconds
|
||||||
* delegates to {@link #saveSection(Instant, Instant, String, String)}.
|
* earlier (with millisecond precision), then delegates to {@link #saveSection(Instant, Instant)}.
|
||||||
*
|
*
|
||||||
* @param startMarkerId id of the marker whose timestamp begins the section to save
|
* @param duration the length of the clip in seconds, measured back from now; must be greater
|
||||||
* @param endMarkerId id of the marker whose timestamp ends the section to save
|
* than {@code 0} and no greater than {@link #CLIP_MAX_LENGTH}. Fractional values
|
||||||
* @param title title for the saved VoD; defaults to the current timestamp if {@code null}
|
* are supported and resolved to the millisecond.
|
||||||
* @param description description for the saved VoD; defaults to an empty string if {@code null}
|
* @throws IllegalArgumentException if {@code duration} is not in the range {@code (0, CLIP_MAX_LENGTH]},
|
||||||
* @return the {@link Job} tracking the save; state transitions to
|
|
||||||
* {@link JobState#SUCCEEDED} or {@link JobState#FAILED} asynchronously
|
|
||||||
* @throws MarkerNotFound if either marker id does not exist
|
|
||||||
* @throws NotAuthenticated if no user is currently authenticated, or the caller does not
|
|
||||||
* own one of the referenced markers
|
|
||||||
* @throws IllegalArgumentException if the start marker's timestamp is not before the end
|
|
||||||
* marker's, or if no stream segments exist in the given range
|
|
||||||
* @throws IOException if reading the stream directory or its segments fails
|
|
||||||
*/
|
|
||||||
public Job saveSection(
|
|
||||||
Long startMarkerId,
|
|
||||||
Long endMarkerId,
|
|
||||||
String title,
|
|
||||||
String description
|
|
||||||
) throws IOException {
|
|
||||||
Marker start = markerService.getMarkerById(startMarkerId);
|
|
||||||
Marker end = markerService.getMarkerById(endMarkerId);
|
|
||||||
|
|
||||||
return saveSection(start.getTimestamp(), end.getTimestamp(), title, description);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Saves the last {@code duration} seconds of the currently authenticated user's live stream as a clip.
|
|
||||||
* <p>
|
|
||||||
* Computes a window of {@code [now - duration, now)} with millisecond precision, locates the
|
|
||||||
* overlapping HLS {@code .ts} segments, and delegates extraction and muxing to
|
|
||||||
* {@link StreamActionsService#saveSection}. On success, the output is persisted as a clip via
|
|
||||||
* {@link ClipService#persistClip}.
|
|
||||||
*
|
|
||||||
* @param duration length of the clip in seconds measured back from now; must be in
|
|
||||||
* {@code (0, CLIP_MAX_LENGTH]}. Fractional values are supported.
|
|
||||||
* @param title title for the saved clip; defaults to the current timestamp if {@code null}
|
|
||||||
* @param description description for the saved clip; defaults to an empty string if {@code null}
|
|
||||||
* @return the {@link Job} tracking the save; state transitions to
|
|
||||||
* {@link JobState#SUCCEEDED} or {@link JobState#FAILED} asynchronously
|
|
||||||
* @throws IllegalArgumentException if {@code duration} is not in {@code (0, CLIP_MAX_LENGTH]},
|
|
||||||
* or if no stream segments exist in the computed window
|
* or if no stream segments exist in the computed window
|
||||||
* @throws NotAuthenticated if no user is currently authenticated
|
* @throws NotAuthenticated if no user is currently authenticated
|
||||||
* @throws IOException if reading the stream directory or its segments fails
|
* @throws IOException if reading the stream directory or its segments fails
|
||||||
*/
|
*/
|
||||||
public Job clip(float duration, String title, String description) throws IOException {
|
public Job clip(float duration) throws IOException {
|
||||||
if (duration <= 0 || duration > CLIP_MAX_LENGTH) {
|
if (duration <= 0 || duration > CLIP_MAX_LENGTH) {
|
||||||
throw new IllegalArgumentException("Clip length must be between 0 and " + CLIP_MAX_LENGTH + " seconds");
|
throw new IllegalArgumentException("Clip length must be between 0 and " + CLIP_MAX_LENGTH + " seconds");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// minusSeconds() is possible, but only does integer seconds, not float
|
||||||
Instant endTime = Instant.now();
|
Instant endTime = Instant.now();
|
||||||
// minusSeconds() only does integer seconds, not float
|
|
||||||
Instant startTime = endTime.minus(Duration.ofMillis((long) (duration * 1000)));
|
Instant startTime = endTime.minus(Duration.ofMillis((long) (duration * 1000)));
|
||||||
|
|
||||||
User user = userService.getLoggedInUser()
|
User user = userService.getLoggedInUser()
|
||||||
.orElseThrow(() -> new NotAuthenticated("User is not authenticated"));
|
.orElseThrow(() -> new NotAuthenticated("User is not authenticated"));
|
||||||
|
|
||||||
Job job = jobRegistryService.generateJob();
|
Job job = jobRegistryService.generateJob();
|
||||||
SegmentContext ctx = resolveSegments(user, startTime, endTime);
|
Path streamDirectory = directoryService.getStreamDir(user.getStreamKey());
|
||||||
Path outputFile = directoryService.getTempOutputDir().resolve(UUID.randomUUID() + ".mp4");
|
List<Path> fileSegments = getSegmentsInRange(streamDirectory, startTime, endTime);
|
||||||
|
|
||||||
return dispatchSaveJob(job, ctx, duration, outputFile, () ->
|
if (fileSegments.isEmpty()) {
|
||||||
clipService.persistClip(
|
|
||||||
title == null ? Instant.now().toString() : title,
|
|
||||||
description == null ? "" : description,
|
|
||||||
user,
|
|
||||||
outputFile,
|
|
||||||
outputFile.getFileName().toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private record SegmentContext(List<Path> segments, float trimOffset) {}
|
|
||||||
|
|
||||||
private SegmentContext resolveSegments(User user, Instant startTime, Instant endTime) throws IOException {
|
|
||||||
Path streamDir = directoryService.getStreamDir(user.getStreamKey());
|
|
||||||
List<Path> segments = streamActionsService.getSegmentsInRange(streamDir, startTime, endTime);
|
|
||||||
if (segments.isEmpty()) {
|
|
||||||
throw new IllegalArgumentException("No stream segments found in the given time range");
|
throw new IllegalArgumentException("No stream segments found in the given time range");
|
||||||
}
|
}
|
||||||
long firstMs = streamActionsService.parseTimestampMs(segments.getFirst());
|
|
||||||
float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstMs) / 1000f);
|
|
||||||
return new SegmentContext(segments, trimOffset);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Job dispatchSaveJob(Job job, SegmentContext ctx, float duration, Path outputFile, Runnable onSuccess) {
|
long firstSegmentMs = parseTimestampMs(fileSegments.getFirst());
|
||||||
job.setState(JobState.PROCESSING);
|
float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstSegmentMs) / 1000f);
|
||||||
streamActionsService.saveSection(ctx.segments(), ctx.trimOffset(), duration, outputFile, job.getProgressTracker())
|
|
||||||
|
Path outputFile = directoryService.getTempOutputDir().resolve(UUID.randomUUID() + ".mp4");
|
||||||
|
streamActionsService.saveSection(fileSegments, trimOffset, duration, outputFile, job.getProgressTracker())
|
||||||
.thenRun(() -> {
|
.thenRun(() -> {
|
||||||
job.setDownload(outputFile);
|
job.setDownload(outputFile);
|
||||||
job.setState(JobState.SUCCEEDED);
|
job.setState(JobState.SUCCEEDED);
|
||||||
logger.info("Stream save job {} succeeded", job.getUuid());
|
logger.info("Stream clip of job {} succeeded", job.getUuid());
|
||||||
onSuccess.run();
|
clipService.persistClip(
|
||||||
}).exceptionally(ex -> {
|
Instant.now().toString(),
|
||||||
|
"",
|
||||||
|
user,
|
||||||
|
outputFile,
|
||||||
|
outputFile.getFileName().toString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
).exceptionally(ex -> {
|
||||||
job.setState(JobState.FAILED);
|
job.setState(JobState.FAILED);
|
||||||
job.setErrorOutput(ex.getMessage());
|
job.setErrorOutput(ex.getMessage());
|
||||||
logger.error("Stream save job {} failed: {}", job.getUuid(), ex.getMessage());
|
logger.error("Clip save job with UUID {} failed due to: {}", job.getUuid(), ex.getMessage());
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
return job;
|
return job;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<Path> getSegmentsInRange(Path streamDirectory, Instant startTime, Instant endTime) throws IOException {
|
||||||
|
long startMs = startTime.toEpochMilli();
|
||||||
|
long endMs = endTime.toEpochMilli();
|
||||||
|
|
||||||
|
try (Stream<Path> files = Files.list(streamDirectory)) {
|
||||||
|
return files
|
||||||
|
.filter(p -> p.getFileName().toString().endsWith(".ts"))
|
||||||
|
.filter(p -> {
|
||||||
|
long segMs = parseTimestampMs(p);
|
||||||
|
return segMs < endMs && (segMs + HLS_SEGMENT_LENGTH * 1000) > startMs;
|
||||||
|
})
|
||||||
|
.sorted(Comparator.comparingLong(this::parseTimestampMs))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private long parseTimestampMs(Path path) {
|
||||||
|
String name = path.getFileName().toString().replace(".ts", "");
|
||||||
|
long value = Long.parseLong(name);
|
||||||
|
// nginx hls_fragment_naming system uses seconds; convert to ms
|
||||||
|
return value < 1_000_000_000_000L ? value * 1000L : value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,261 +0,0 @@
|
|||||||
package com.ddf.vodsystem.services;
|
|
||||||
|
|
||||||
import com.ddf.vodsystem.controllers.dto.VodUpdateRequest;
|
|
||||||
import com.ddf.vodsystem.dto.ClipOptions;
|
|
||||||
import com.ddf.vodsystem.entities.User;
|
|
||||||
import com.ddf.vodsystem.entities.Vod;
|
|
||||||
import com.ddf.vodsystem.exceptions.*;
|
|
||||||
import com.ddf.vodsystem.repositories.VodRepository;
|
|
||||||
import com.ddf.vodsystem.services.media.MetadataService;
|
|
||||||
import com.ddf.vodsystem.services.media.ThumbnailService;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.core.io.FileSystemResource;
|
|
||||||
import org.springframework.core.io.Resource;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.concurrent.ExecutionException;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
public class VodService {
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(VodService.class);
|
|
||||||
|
|
||||||
private final VodRepository vodRepository;
|
|
||||||
private final DirectoryService directoryService;
|
|
||||||
private final MetadataService metadataService;
|
|
||||||
private final ThumbnailService thumbnailService;
|
|
||||||
private final UserService userService;
|
|
||||||
|
|
||||||
public VodService(VodRepository vodRepository,
|
|
||||||
DirectoryService directoryService,
|
|
||||||
MetadataService metadataService,
|
|
||||||
ThumbnailService thumbnailService,
|
|
||||||
UserService userService) {
|
|
||||||
this.vodRepository = vodRepository;
|
|
||||||
this.directoryService = directoryService;
|
|
||||||
this.metadataService = metadataService;
|
|
||||||
this.thumbnailService = thumbnailService;
|
|
||||||
this.userService = userService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the VoD with the given ID, verifying ownership by the current user.
|
|
||||||
*
|
|
||||||
* @param id the ID of the VoD to retrieve
|
|
||||||
* @return the matching {@link Vod} entity
|
|
||||||
* @throws VodNotFound if no VoD with {@code id} exists
|
|
||||||
* @throws NotAuthenticated if the current user does not own the VoD
|
|
||||||
*/
|
|
||||||
public Vod getVodById(Long id) {
|
|
||||||
Optional<Vod> vod = vodRepository.findById(id);
|
|
||||||
|
|
||||||
if (vod.isEmpty()) {
|
|
||||||
throw new VodNotFound("Vod ID " + id + " does not exist");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAuthenticatedForVod(vod.get())) {
|
|
||||||
throw new NotAuthenticated("User not authenticated");
|
|
||||||
}
|
|
||||||
|
|
||||||
return vod.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns all VoDs belonging to the currently authenticated user.
|
|
||||||
*
|
|
||||||
* @return list of VoDs owned by the current user, in repository order
|
|
||||||
* @throws NotAuthenticated if no user session is present
|
|
||||||
*/
|
|
||||||
public List<Vod> getUserVods() {
|
|
||||||
Optional<User> user = userService.getLoggedInUser();
|
|
||||||
|
|
||||||
if (user.isEmpty()) {
|
|
||||||
throw new NotAuthenticated("User must be logged in to retrieve vods");
|
|
||||||
}
|
|
||||||
|
|
||||||
return vodRepository.findByUser(user.get());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies a partial update to an existing VoD's metadata.
|
|
||||||
* <p>
|
|
||||||
* Only non-null fields in {@code newFields} are written; null fields are left unchanged.
|
|
||||||
*
|
|
||||||
* @param id the ID of the VoD to update
|
|
||||||
* @param newFields fields to overwrite; any null field is ignored
|
|
||||||
* @return the updated VoD as persisted in the database
|
|
||||||
* @throws VodNotFound if no VoD with {@code id} exists
|
|
||||||
* @throws NotAuthenticated if the current user does not own the VoD
|
|
||||||
*/
|
|
||||||
public Vod updateVod(Long id, VodUpdateRequest newFields) {
|
|
||||||
Vod vod = getVodById(id);
|
|
||||||
|
|
||||||
if (newFields.title() != null) {
|
|
||||||
vod.setTitle(newFields.title());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newFields.description() != null) {
|
|
||||||
vod.setDescription(newFields.description());
|
|
||||||
}
|
|
||||||
|
|
||||||
return vodRepository.saveAndFlush(vod);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a VoD and its associated files from disk.
|
|
||||||
*
|
|
||||||
* @param id the ID of the VoD to delete
|
|
||||||
* @throws VodNotFound if no VoD with {@code id} exists
|
|
||||||
* @throws NotAuthenticated if the current user does not own the VoD
|
|
||||||
*/
|
|
||||||
public void deleteVod(Long id) {
|
|
||||||
Vod vod = getVodById(id);
|
|
||||||
deleteVodFiles(vod);
|
|
||||||
vodRepository.delete(vod);
|
|
||||||
|
|
||||||
logger.info("Vod with ID {} deleted successfully", id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a streamable resource for the VoD's video file.
|
|
||||||
*
|
|
||||||
* @param id the ID of the VoD to download
|
|
||||||
* @return a {@link Resource} pointing to the VoD's video file on disk
|
|
||||||
* @throws VodNotFound if no VoD with {@code id} exists, or the video file is missing on disk
|
|
||||||
* @throws NotAuthenticated if the current user does not own the VoD
|
|
||||||
*/
|
|
||||||
public Resource downloadVod(Long id) {
|
|
||||||
Vod vod = getVodById(id);
|
|
||||||
Path file = directoryService.resolvePath(vod.getVideoPath());
|
|
||||||
|
|
||||||
if (!Files.exists(file)) {
|
|
||||||
throw new VodNotFound("Vod file not found for ID: " + id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Files.isReadable(file)) {
|
|
||||||
throw new VodMediaUnavailable(id, "Vod file is not readable");
|
|
||||||
}
|
|
||||||
|
|
||||||
return new FileSystemResource(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a streamable resource for the VoD's thumbnail image.
|
|
||||||
*
|
|
||||||
* @param id the ID of the VoD whose thumbnail to download
|
|
||||||
* @return a {@link Resource} pointing to the thumbnail file on disk
|
|
||||||
* @throws VodNotFound if no VoD with {@code id} exists, or the thumbnail file is missing on disk
|
|
||||||
* @throws NotAuthenticated if the current user does not own the VoD
|
|
||||||
*/
|
|
||||||
public Resource downloadThumbnail(Long id) {
|
|
||||||
Vod vod = getVodById(id);
|
|
||||||
Path file = directoryService.resolvePath(vod.getThumbnailPath());
|
|
||||||
|
|
||||||
if (!Files.exists(file)) {
|
|
||||||
throw new VodNotFound("Thumbnail file not found for vod ID: " + id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new FileSystemResource(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copies a processed VoD file into permanent storage, generates its thumbnail, and saves
|
|
||||||
* the VoD record to the database.
|
|
||||||
* <p>
|
|
||||||
* Thumbnail generation failure is non-fatal — the error is logged and the VoD is saved
|
|
||||||
* without a thumbnail rather than aborting.
|
|
||||||
*
|
|
||||||
* @param title title to assign to the VoD
|
|
||||||
* @param description description to assign to the VoD
|
|
||||||
* @param user owner of the VoD
|
|
||||||
* @param vodFile path to the processed video file to copy into permanent storage
|
|
||||||
* @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 VoDs directory fails
|
|
||||||
* @throws FFMPEGException if reading video metadata from the copied file fails
|
|
||||||
*/
|
|
||||||
public void persist(String title,
|
|
||||||
String description,
|
|
||||||
User user,
|
|
||||||
Path vodFile,
|
|
||||||
String fileName) {
|
|
||||||
Path newVodFile;
|
|
||||||
Path thumbnailFile;
|
|
||||||
|
|
||||||
try {
|
|
||||||
newVodFile = directoryService.getVodsDir(user.getId()).resolve(fileName);
|
|
||||||
thumbnailFile = directoryService.getThumbnailsDir(user.getId()).resolve(fileName + ".png");
|
|
||||||
directoryService.copyFile(vodFile, newVodFile);
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new StorageException("Failed to move vod from temporary directory to output directory", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
ClipOptions vodMetadata;
|
|
||||||
try {
|
|
||||||
vodMetadata = metadataService.getVideoMetadata(newVodFile).get();
|
|
||||||
} catch (InterruptedException | ExecutionException e) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
throw new FFMPEGException("Error retrieving video metadata for vod: " + e.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
thumbnailService.createThumbnail(newVodFile, thumbnailFile, 0.0f)
|
|
||||||
.exceptionally(ex -> {
|
|
||||||
logger.error("Thumbnail job for user {} failed: {}", user.getId(), ex.toString());
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
vodMetadata.setTitle(title);
|
|
||||||
vodMetadata.setDescription(description);
|
|
||||||
|
|
||||||
Vod vod = saveToDb(vodMetadata, user, newVodFile, thumbnailFile);
|
|
||||||
logger.info("Vod created successfully with ID: {}", vod.getId());
|
|
||||||
}
|
|
||||||
|
|
||||||
private Vod saveToDb(ClipOptions options,
|
|
||||||
User user,
|
|
||||||
Path videoPath,
|
|
||||||
Path thumbnailPath) {
|
|
||||||
Vod vod = new Vod();
|
|
||||||
vod.setUser(user);
|
|
||||||
vod.setTitle(options.getTitle() != null ? options.getTitle() : "Untitled VoD");
|
|
||||||
vod.setDescription(options.getDescription() != null ? options.getDescription() : "");
|
|
||||||
vod.setCreatedAt(Instant.now());
|
|
||||||
vod.setWidth(options.getWidth());
|
|
||||||
vod.setHeight(options.getHeight());
|
|
||||||
vod.setFps(options.getFps());
|
|
||||||
vod.setDuration(options.getDuration());
|
|
||||||
vod.setFileSize(options.getFileSize());
|
|
||||||
vod.setVideoPath(directoryService.relativisePath(videoPath.toAbsolutePath()).toString());
|
|
||||||
vod.setThumbnailPath(directoryService.relativisePath(thumbnailPath.toAbsolutePath()).toString());
|
|
||||||
return vodRepository.save(vod);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void deleteVodFiles(Vod vod) {
|
|
||||||
File vodFile = new File(vod.getVideoPath());
|
|
||||||
File thumbnailFile = new File(vod.getThumbnailPath());
|
|
||||||
|
|
||||||
try {
|
|
||||||
Files.deleteIfExists(vodFile.toPath());
|
|
||||||
Files.deleteIfExists(thumbnailFile.toPath());
|
|
||||||
} catch (IOException e) {
|
|
||||||
logger.error("Could not delete vod files for vod ID: {}", vod.getId());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isAuthenticatedForVod(Vod vod) {
|
|
||||||
Optional<User> user = userService.getLoggedInUser();
|
|
||||||
|
|
||||||
if (vod == null || user.isEmpty()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return user.get().getId().equals(vod.getUser().getId());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ package com.ddf.vodsystem.services.media;
|
|||||||
|
|
||||||
import com.ddf.vodsystem.dto.CommandOutput;
|
import com.ddf.vodsystem.dto.CommandOutput;
|
||||||
import com.ddf.vodsystem.dto.ProgressTracker;
|
import com.ddf.vodsystem.dto.ProgressTracker;
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.io.BufferedReader;
|
import java.io.BufferedReader;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -12,11 +11,10 @@ import java.util.function.Consumer;
|
|||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
@Component
|
|
||||||
public class CommandRunner {
|
public class CommandRunner {
|
||||||
private static final Pattern timePattern = Pattern.compile("out_time_ms=(\\d+)");
|
private static final Pattern timePattern = Pattern.compile("out_time_ms=(\\d+)");
|
||||||
|
|
||||||
public CommandOutput run(List<String> command, Consumer<String> onOutput) throws IOException, InterruptedException {
|
public static CommandOutput run(List<String> command, Consumer<String> onOutput) throws IOException, InterruptedException {
|
||||||
ProcessBuilder processBuilder = new ProcessBuilder(command);
|
ProcessBuilder processBuilder = new ProcessBuilder(command);
|
||||||
processBuilder.redirectErrorStream(true);
|
processBuilder.redirectErrorStream(true);
|
||||||
|
|
||||||
@@ -43,15 +41,19 @@ public class CommandRunner {
|
|||||||
return commandOutput;
|
return commandOutput;
|
||||||
}
|
}
|
||||||
|
|
||||||
public CommandOutput run(List<String> command) throws IOException, InterruptedException {
|
public static CommandOutput run(List<String> command) throws IOException, InterruptedException {
|
||||||
return run(command, null);
|
return run(command, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setProgress(String line, ProgressTracker progress, float length) {
|
public static void setProgress(String line, ProgressTracker progress, float length) {
|
||||||
Matcher matcher = timePattern.matcher(line);
|
Matcher matcher = timePattern.matcher(line);
|
||||||
if (matcher.find()) {
|
if (matcher.find()) {
|
||||||
float timeInMs = Float.parseFloat(matcher.group(1)) / 1000000f;
|
float timeInMs = Float.parseFloat(matcher.group(1)) / 1000000f;
|
||||||
progress.setProgress(timeInMs / length);
|
progress.setProgress(timeInMs / length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private CommandRunner() {
|
||||||
|
// Private constructor to prevent instantiation
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -13,45 +13,42 @@ import java.nio.file.Path;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class CompressionService {
|
public class CompressionService {
|
||||||
private static final Logger logger = LoggerFactory.getLogger(CompressionService.class);
|
private static final Logger logger = LoggerFactory.getLogger(CompressionService.class);
|
||||||
private final CommandRunner commandRunner;
|
|
||||||
|
|
||||||
private static final float AUDIO_RATIO = 0.15f;
|
private static final float AUDIO_RATIO = 0.15f;
|
||||||
private static final float MAX_AUDIO_BITRATE = 128f;
|
private static final float MAX_AUDIO_BITRATE = 128f;
|
||||||
private static final float BITRATE_MULTIPLIER = 0.9f;
|
private static final float BITRATE_MULTIPLIER = 0.9f;
|
||||||
|
private final Pattern timePattern = Pattern.compile("out_time_ms=(\\d+)");
|
||||||
public CompressionService(CommandRunner commandRunner) {
|
|
||||||
this.commandRunner = commandRunner;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Async("ffmpegTaskExecutor")
|
@Async("ffmpegTaskExecutor")
|
||||||
public CompletableFuture<CommandOutput> compress(Path inputFile,
|
public CompletableFuture<CommandOutput> compress(Path inputFile,
|
||||||
Path outputFile,
|
Path outputFile,
|
||||||
ClipOptions clipOptions,
|
ClipOptions clipOptions,
|
||||||
ProgressTracker progress
|
ProgressTracker progress
|
||||||
) {
|
) throws IOException, InterruptedException {
|
||||||
logger.info("Compressing video from {} to {}", inputFile.toAbsolutePath(), outputFile.toAbsolutePath());
|
logger.info("Compressing video from {} to {}", inputFile.toAbsolutePath(), outputFile.toAbsolutePath());
|
||||||
|
|
||||||
try {
|
|
||||||
List<String> command = buildCommand(
|
List<String> command = buildCommand(
|
||||||
inputFile,
|
inputFile,
|
||||||
outputFile,
|
outputFile,
|
||||||
clipOptions
|
clipOptions
|
||||||
);
|
);
|
||||||
CommandOutput result = commandRunner.run(command, line -> commandRunner.setProgress(line, progress, clipOptions.getDuration()));
|
CommandOutput result = CommandRunner.run(command, line -> CommandRunner.setProgress(line, progress, clipOptions.getDuration()));
|
||||||
progress.markComplete();
|
progress.markComplete();
|
||||||
|
|
||||||
return CompletableFuture.completedFuture(result);
|
return CompletableFuture.completedFuture(result);
|
||||||
} catch (IOException e) {
|
}
|
||||||
logger.error("IO error on compress call: {}", e.toString());
|
|
||||||
return CompletableFuture.failedFuture(e);
|
private void setProgress(String line, ProgressTracker progress, float length) {
|
||||||
} catch (InterruptedException e) {
|
Matcher matcher = timePattern.matcher(line);
|
||||||
logger.error("Thread error on compress call: {}", e.toString());
|
if (matcher.find()) {
|
||||||
Thread.currentThread().interrupt();
|
float timeInMs = Float.parseFloat(matcher.group(1)) / 1000000f;
|
||||||
return CompletableFuture.failedFuture(e);
|
progress.setProgress(timeInMs / length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +75,7 @@ public class CompressionService {
|
|||||||
return command;
|
return command;
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<String> buildBitrate(Float length, Long fileSize) {
|
private List<String> buildBitrate(Float length, Float fileSize) {
|
||||||
List<String> command = new ArrayList<>();
|
List<String> command = new ArrayList<>();
|
||||||
|
|
||||||
float bitrate = ((fileSize * 8) / length) * BITRATE_MULTIPLIER;
|
float bitrate = ((fileSize * 8) / length) * BITRATE_MULTIPLIER;
|
||||||
@@ -115,7 +112,7 @@ public class CompressionService {
|
|||||||
return command;
|
return command;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected List<String> buildCommand(Path inputFile, Path outputFile, ClipOptions clipOptions) {
|
private List<String> buildCommand(Path inputFile, Path outputFile, ClipOptions clipOptions) {
|
||||||
List<String> command = new ArrayList<>();
|
List<String> command = new ArrayList<>();
|
||||||
command.add("ffmpeg");
|
command.add("ffmpeg");
|
||||||
command.add("-progress");
|
command.add("-progress");
|
||||||
|
|||||||
@@ -14,20 +14,16 @@ import java.io.IOException;
|
|||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.Future;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class MetadataService {
|
public class MetadataService {
|
||||||
private static final Logger logger = LoggerFactory.getLogger(MetadataService.class);
|
private static final Logger logger = LoggerFactory.getLogger(MetadataService.class);
|
||||||
public final CommandRunner commandRunner;
|
|
||||||
|
|
||||||
private static final String DURATION = "duration";
|
private static final String DURATION = "duration";
|
||||||
|
|
||||||
public MetadataService(CommandRunner commandRunner) {
|
|
||||||
this.commandRunner = commandRunner;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Async("ffmpegTaskExecutor")
|
@Async("ffmpegTaskExecutor")
|
||||||
public CompletableFuture<ClipOptions> getVideoMetadata(Path file) {
|
public Future<ClipOptions> getVideoMetadata(Path file) {
|
||||||
logger.info("Getting metadata for file {}", file);
|
logger.info("Getting metadata for file {}", file);
|
||||||
|
|
||||||
List<String> command = List.of(
|
List<String> command = List.of(
|
||||||
@@ -43,7 +39,7 @@ public class MetadataService {
|
|||||||
StringBuilder outputBuilder = new StringBuilder();
|
StringBuilder outputBuilder = new StringBuilder();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
CommandOutput output = commandRunner.run(command);
|
CommandOutput output = CommandRunner.run(command);
|
||||||
|
|
||||||
for (String line : output.getOutput()) {
|
for (String line : output.getOutput()) {
|
||||||
outputBuilder.append(line);
|
outputBuilder.append(line);
|
||||||
@@ -51,11 +47,9 @@ public class MetadataService {
|
|||||||
|
|
||||||
JsonNode node = mapper.readTree(outputBuilder.toString());
|
JsonNode node = mapper.readTree(outputBuilder.toString());
|
||||||
return CompletableFuture.completedFuture(parseVideoMetadata(node));
|
return CompletableFuture.completedFuture(parseVideoMetadata(node));
|
||||||
} catch (IOException e) {
|
} catch (IOException | InterruptedException e) {
|
||||||
return CompletableFuture.failedFuture(e);
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
return CompletableFuture.failedFuture(e);
|
throw new FFMPEGException("Error while getting video metadata: " + e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,12 +58,13 @@ public class MetadataService {
|
|||||||
metadata.setStartPoint(0f);
|
metadata.setStartPoint(0f);
|
||||||
|
|
||||||
JsonNode streamNode = extractStreamNode(node);
|
JsonNode streamNode = extractStreamNode(node);
|
||||||
JsonNode formatNode = extractFormatNode(node);
|
|
||||||
|
|
||||||
metadata.setDuration(extractDuration(streamNode, formatNode));
|
metadata.setDuration(extractDuration(streamNode));
|
||||||
metadata.setWidth(getWidth(streamNode));
|
metadata.setWidth(getWidth(streamNode));
|
||||||
metadata.setHeight(getHeight(streamNode));
|
metadata.setHeight(getHeight(streamNode));
|
||||||
metadata.setFps(extractFps(streamNode));
|
metadata.setFps(extractFps(streamNode));
|
||||||
|
|
||||||
|
JsonNode formatNode = extractFormatNode(node);
|
||||||
metadata.setFileSize(extractFileSize(formatNode));
|
metadata.setFileSize(extractFileSize(formatNode));
|
||||||
extractEndPointFromFormat(metadata, formatNode);
|
extractEndPointFromFormat(metadata, formatNode);
|
||||||
|
|
||||||
@@ -84,15 +79,11 @@ public class MetadataService {
|
|||||||
return streamNode;
|
return streamNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Float extractDuration(JsonNode streamNode, JsonNode formatNode) {
|
private Float extractDuration(JsonNode streamNode) {
|
||||||
if (streamNode.has(DURATION)) {
|
if (streamNode.has(DURATION)) {
|
||||||
return Float.valueOf(streamNode.get(DURATION).asText());
|
return Float.valueOf(streamNode.get(DURATION).asText());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formatNode.has(DURATION)) {
|
|
||||||
return Float.valueOf(formatNode.get(DURATION).asText());
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new FFMPEGException("ffprobe duration missing");
|
throw new FFMPEGException("ffprobe duration missing");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,9 +108,9 @@ public class MetadataService {
|
|||||||
return (formatNode != null && !formatNode.isMissingNode()) ? formatNode : null;
|
return (formatNode != null && !formatNode.isMissingNode()) ? formatNode : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Long extractFileSize(JsonNode formatNode) {
|
private Float extractFileSize(JsonNode formatNode) {
|
||||||
if (formatNode != null && formatNode.has("size")) {
|
if (formatNode != null && formatNode.has("size")) {
|
||||||
return Long.parseLong(formatNode.get("size").asText());
|
return Float.parseFloat(formatNode.get("size").asText());
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new FFMPEGException("ffprobe file size missing");
|
throw new FFMPEGException("ffprobe file size missing");
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ package com.ddf.vodsystem.services.media;
|
|||||||
|
|
||||||
import com.ddf.vodsystem.dto.CommandOutput;
|
import com.ddf.vodsystem.dto.CommandOutput;
|
||||||
import com.ddf.vodsystem.dto.ProgressTracker;
|
import com.ddf.vodsystem.dto.ProgressTracker;
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.scheduling.annotation.Async;
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -11,23 +9,19 @@ import java.io.File;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class RemuxService {
|
public class RemuxService {
|
||||||
private static final Logger logger = LoggerFactory.getLogger(RemuxService.class);
|
private final Pattern timePattern = Pattern.compile("out_time_ms=(\\d+)");
|
||||||
private final CommandRunner commandRunner;
|
|
||||||
|
|
||||||
public RemuxService(CommandRunner commandRunner) {
|
|
||||||
this.commandRunner = commandRunner;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Async("ffmpegTaskExecutor")
|
@Async("ffmpegTaskExecutor")
|
||||||
public CompletableFuture<CommandOutput> remux(File inputFile,
|
public CompletableFuture<CommandOutput> remux(File inputFile,
|
||||||
File outputFile,
|
File outputFile,
|
||||||
ProgressTracker remuxProgress,
|
ProgressTracker remuxProgress,
|
||||||
float length
|
float length
|
||||||
) {
|
) throws IOException, InterruptedException {
|
||||||
try {
|
|
||||||
List<String> command = List.of(
|
List<String> command = List.of(
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-progress", "pipe:1",
|
"-progress", "pipe:1",
|
||||||
@@ -39,15 +33,14 @@ public class RemuxService {
|
|||||||
outputFile.getAbsolutePath()
|
outputFile.getAbsolutePath()
|
||||||
);
|
);
|
||||||
|
|
||||||
return CompletableFuture.completedFuture(commandRunner.run(command, line ->
|
return CompletableFuture.completedFuture(CommandRunner.run(command, line -> setProgress(line, remuxProgress, length)));
|
||||||
commandRunner.setProgress(line, remuxProgress, length)));
|
}
|
||||||
} catch (IOException e) {
|
|
||||||
logger.error("IO error on remux call: {}", e.toString());
|
private void setProgress(String line, ProgressTracker progress, float length) {
|
||||||
return CompletableFuture.failedFuture(e);
|
Matcher matcher = timePattern.matcher(line);
|
||||||
} catch (InterruptedException e) {
|
if (matcher.find()) {
|
||||||
logger.error("Thread error on remux call: {}", e.toString());
|
float timeInMs = Float.parseFloat(matcher.group(1)) / 1000000f;
|
||||||
Thread.currentThread().interrupt();
|
progress.setProgress(timeInMs / length);
|
||||||
return CompletableFuture.failedFuture(e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.ddf.vodsystem.services.media;
|
package com.ddf.vodsystem.services.media;
|
||||||
|
|
||||||
import com.ddf.vodsystem.dto.CommandOutput;
|
|
||||||
import com.ddf.vodsystem.dto.ProgressTracker;
|
import com.ddf.vodsystem.dto.ProgressTracker;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -8,14 +7,10 @@ import org.springframework.scheduling.annotation.Async;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import java.util.stream.Stream;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service responsible for performing stream-related media actions, such as
|
* Service responsible for performing stream-related media actions, such as
|
||||||
@@ -29,13 +24,6 @@ import java.util.stream.Stream;
|
|||||||
@Service
|
@Service
|
||||||
public class StreamActionsService {
|
public class StreamActionsService {
|
||||||
private static final Logger logger = LoggerFactory.getLogger(StreamActionsService.class);
|
private static final Logger logger = LoggerFactory.getLogger(StreamActionsService.class);
|
||||||
private final CommandRunner commandRunner;
|
|
||||||
|
|
||||||
private static final int HLS_SEGMENT_LENGTH = 3;
|
|
||||||
|
|
||||||
public StreamActionsService(CommandRunner commandRunner) {
|
|
||||||
this.commandRunner = commandRunner;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Concatenates a list of video segments and extracts a trimmed section
|
* Concatenates a list of video segments and extracts a trimmed section
|
||||||
@@ -55,11 +43,12 @@ public class StreamActionsService {
|
|||||||
* to extract
|
* to extract
|
||||||
* @param outputFile the destination file the trimmed section will be
|
* @param outputFile the destination file the trimmed section will be
|
||||||
* written to; overwritten if it already exists
|
* written to; overwritten if it already exists
|
||||||
* @return a {@link CompletableFuture} that completes with the ffmpeg
|
* @return a {@link CompletableFuture} that completes with
|
||||||
* command output on success, or completes exceptionally on failure
|
* {@code outputFile} on success, or completes exceptionally on
|
||||||
|
* failure
|
||||||
*/
|
*/
|
||||||
@Async("ffmpegTaskExecutor")
|
@Async("ffmpegTaskExecutor")
|
||||||
public CompletableFuture<CommandOutput> saveSection(
|
public CompletableFuture<Path> saveSection(
|
||||||
List<Path> segments,
|
List<Path> segments,
|
||||||
float trimOffset,
|
float trimOffset,
|
||||||
float duration,
|
float duration,
|
||||||
@@ -71,10 +60,6 @@ public class StreamActionsService {
|
|||||||
.map(p -> p.toAbsolutePath().toString())
|
.map(p -> p.toAbsolutePath().toString())
|
||||||
.collect(Collectors.joining("|"));
|
.collect(Collectors.joining("|"));
|
||||||
|
|
||||||
if (concatInput.isEmpty()) {
|
|
||||||
throw new IllegalStateException("Must have at least one segment");
|
|
||||||
}
|
|
||||||
|
|
||||||
List<String> command = List.of(
|
List<String> command = List.of(
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-y",
|
"-y",
|
||||||
@@ -87,54 +72,17 @@ public class StreamActionsService {
|
|||||||
|
|
||||||
logger.info("Saving section with ({} segments) to '{}'",
|
logger.info("Saving section with ({} segments) to '{}'",
|
||||||
segments.size(), outputFile);
|
segments.size(), outputFile);
|
||||||
CommandOutput output = commandRunner.run(command, line -> commandRunner.setProgress(line, progressTracker, duration));
|
CommandRunner.run(command, line -> CommandRunner.setProgress(line, progressTracker, duration));
|
||||||
|
|
||||||
progressTracker.markComplete();
|
progressTracker.markComplete();
|
||||||
return CompletableFuture.completedFuture(output);
|
|
||||||
|
return CompletableFuture.completedFuture(outputFile);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
logger.error("IO error on saveSection call: {}", e.toString());
|
logger.error("Failed to save section to '{}'", outputFile, e);
|
||||||
return CompletableFuture.failedFuture(e);
|
return CompletableFuture.failedFuture(e);
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
logger.error("Thread error on saveSection call: {}", e.toString());
|
logger.error("Failed to save section");
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
return CompletableFuture.failedFuture(e);
|
return CompletableFuture.failedFuture(e);
|
||||||
} catch (RuntimeException e) {
|
|
||||||
logger.error("Failed to save section to '{}': {}", outputFile, e.toString());
|
|
||||||
return CompletableFuture.failedFuture(e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Path> getSegmentsInRange(Path streamDirectory, Instant startTime, Instant endTime) throws IOException {
|
|
||||||
long startMs = startTime.toEpochMilli();
|
|
||||||
long endMs = endTime.toEpochMilli();
|
|
||||||
|
|
||||||
if (endTime.toEpochMilli() <= startTime.toEpochMilli()) {
|
|
||||||
throw new IllegalArgumentException("Start time must be before end time");
|
|
||||||
}
|
|
||||||
|
|
||||||
try (Stream<Path> files = Files.list(streamDirectory)) {
|
|
||||||
return files
|
|
||||||
.filter(p -> p.getFileName().toString().endsWith(".ts"))
|
|
||||||
.filter(Files::isRegularFile)
|
|
||||||
.filter(p -> {
|
|
||||||
long segMs = parseTimestampMs(p);
|
|
||||||
return segMs < endMs && (segMs + HLS_SEGMENT_LENGTH * 1000) > startMs;
|
|
||||||
})
|
|
||||||
.sorted(Comparator.comparingLong(this::parseTimestampMs))
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public long parseTimestampMs(Path path) {
|
|
||||||
String fullName = path.getFileName().toString();
|
|
||||||
|
|
||||||
if (!fullName.endsWith(".ts")) {
|
|
||||||
throw new IllegalArgumentException("File must end in ts");
|
|
||||||
}
|
|
||||||
|
|
||||||
String name = fullName.substring(0, fullName.length() - 3);
|
|
||||||
long value = Long.parseLong(name);
|
|
||||||
// nginx hls_fragment_naming system uses seconds; convert to ms
|
|
||||||
return value < 1_000_000_000_000L ? value * 1000L : value;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.ddf.vodsystem.services.media;
|
package com.ddf.vodsystem.services.media;
|
||||||
|
|
||||||
import com.ddf.vodsystem.dto.CommandOutput;
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.scheduling.annotation.Async;
|
import org.springframework.scheduling.annotation.Async;
|
||||||
@@ -9,22 +8,15 @@ import org.springframework.stereotype.Service;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.CompletableFuture;
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class ThumbnailService {
|
public class ThumbnailService {
|
||||||
private static final Logger logger = LoggerFactory.getLogger(ThumbnailService.class);
|
private static final Logger logger = LoggerFactory.getLogger(ThumbnailService.class);
|
||||||
private final CommandRunner commandRunner;
|
|
||||||
|
|
||||||
public ThumbnailService(CommandRunner commandRunner) {
|
|
||||||
this.commandRunner = commandRunner;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Async("ffmpegTaskExecutor")
|
@Async("ffmpegTaskExecutor")
|
||||||
public CompletableFuture<CommandOutput> createThumbnail(Path inputFile, Path outputFile, Float timeInVideo) {
|
public void createThumbnail(Path inputFile, Path outputFile, Float timeInVideo) throws IOException, InterruptedException {
|
||||||
logger.info("Creating thumbnail at {} seconds", timeInVideo);
|
logger.info("Creating thumbnail at {} seconds", timeInVideo);
|
||||||
|
|
||||||
try {
|
|
||||||
List<String> command = List.of(
|
List<String> command = List.of(
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-ss", timeInVideo.toString(),
|
"-ss", timeInVideo.toString(),
|
||||||
@@ -33,13 +25,6 @@ public class ThumbnailService {
|
|||||||
outputFile.toAbsolutePath().toString()
|
outputFile.toAbsolutePath().toString()
|
||||||
);
|
);
|
||||||
|
|
||||||
CommandOutput output = commandRunner.run(command);
|
CommandRunner.run(command);
|
||||||
return CompletableFuture.completedFuture(output);
|
|
||||||
} catch (IOException e) {
|
|
||||||
return CompletableFuture.failedFuture(e);
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
return CompletableFuture.failedFuture(e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ spring.application.name=vodSystem
|
|||||||
# VODs
|
# VODs
|
||||||
spring.servlet.multipart.max-file-size=2GB
|
spring.servlet.multipart.max-file-size=2GB
|
||||||
spring.servlet.multipart.max-request-size=2GB
|
spring.servlet.multipart.max-request-size=2GB
|
||||||
stream.max-length=${STREAM_RING_LENGTH_SECOND}
|
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
spring.datasource.url=${POSTGRES_URL}
|
spring.datasource.url=${POSTGRES_URL}
|
||||||
|
|||||||
25
src/main/resources/db/data.sql
Normal file
25
src/main/resources/db/data.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
INSERT INTO users ( google_id, username, email, name)
|
||||||
|
VALUES
|
||||||
|
('google-uid-001', 'alice', 'alice@example.com', 'Alice Example'),
|
||||||
|
( 'google-uid-002', 'bob', 'bob@example.com', 'Bob Example'),
|
||||||
|
('google-uid-003', 'carol', 'carol@example.com', 'Carol Example'),
|
||||||
|
( 'google-uid-004', 'wizard42', 'gandalf@middle.earth', 'Gandalf the Grey'),
|
||||||
|
( 'google-uid-005', 'catnap', 'whiskers@meowmail.com', 'Sir Whiskers McFluff'),
|
||||||
|
( 'google-uid-006', 'robotron', 'bender@futurama.tv', 'Bender Rodriguez'),
|
||||||
|
('google-uid-007', 'unicorn', 'sparkle@rainbow.com', 'Princess Sparklehoof'),
|
||||||
|
( 'google-uid-008', 'pirate', 'blackbeard@seas.com', 'Edward Teach'),
|
||||||
|
( 'google-uid-009', 'detective', 'holmes@bakerstreet.uk', 'Sherlock Holmes'),
|
||||||
|
( 'google-uid-010', 'timey', 'docbrown@delorean.net', 'Dr. Emmett Brown');
|
||||||
|
|
||||||
|
INSERT INTO clips (user_id, title, description, width, height, fps, duration, file_size, video_path)
|
||||||
|
VALUES
|
||||||
|
(4, 'Fireworks Over Hobbiton', 'A magical display of fireworks by Gandalf.', 1920, 1080, 30, 120, 104857600, '/videos/fireworks_hobbiton.mp4'),
|
||||||
|
(5, 'Catnap Chronicles', 'Sir Whiskers McFluff naps in 12 different positions.', 1280, 720, 24, 60, 52428800, '/videos/catnap_chronicles.mp4'),
|
||||||
|
(6, 'Bite My Shiny Metal...', 'Bender shows off his new upgrades.', 1920, 1080, 60, 45, 73400320, '/videos/bender_upgrades.mp4'),
|
||||||
|
(7, 'Rainbow Dash', 'Princess Sparklehoof gallops across a double rainbow.', 1920, 1080, 30, 90, 67108864, '/videos/rainbow_dash.mp4'),
|
||||||
|
( 8, 'Pirate Karaoke Night', 'Blackbeard sings sea shanties with his crew.', 1280, 720, 25, 180, 157286400, '/videos/pirate_karaoke.mp4'),
|
||||||
|
( 9, 'The Case of the Missing Sandwich', 'Sherlock Holmes investigates a lunchtime mystery.', 1920, 1080, 30, 75, 50331648, '/videos/missing_sandwich.mp4'),
|
||||||
|
( 10, '88 Miles Per Hour', 'Doc Brown demonstrates time travel with style.', 1920, 1080, 60, 30, 41943040, '/videos/88mph.mp4'),
|
||||||
|
( 1, 'Alice in Videoland', 'Alice explores a surreal digital wonderland.', 1280, 720, 30, 150, 94371840, '/videos/alice_videoland.mp4'),
|
||||||
|
( 2, 'Bob''s Building Bonanza', 'Bob constructs a house out of cheese.', 1920, 1080, 24, 200, 209715200, '/videos/bob_cheesehouse.mp4'),
|
||||||
|
( 3, 'Carol''s Coding Catastrophe', 'Carol debugs a spaghetti codebase.', 1280, 720, 30, 100, 73400320, '/videos/carol_coding.mp4');
|
||||||
@@ -34,35 +34,8 @@ CREATE TABLE IF NOT EXISTS clips (
|
|||||||
height INTEGER NOT NULL,
|
height INTEGER NOT NULL,
|
||||||
fps FLOAT NOT NULL,
|
fps FLOAT NOT NULL,
|
||||||
duration FLOAT NOT NULL,
|
duration FLOAT NOT NULL,
|
||||||
file_size BIGINT NOT NULL,
|
file_size FLOAT NOT NULL,
|
||||||
video_path VARCHAR(255) NOT NULL,
|
video_path VARCHAR(255) NOT NULL,
|
||||||
thumbnail_path VARCHAR(255) NOT NULL,
|
thumbnail_path VARCHAR(255) NOT NULL,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS vods (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
user_id BIGINT NOT NULL,
|
|
||||||
title VARCHAR(255) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
width INTEGER NOT NULL,
|
|
||||||
height INTEGER NOT NULL,
|
|
||||||
fps FLOAT NOT NULL,
|
|
||||||
duration FLOAT NOT NULL,
|
|
||||||
file_size BIGINT NOT NULL,
|
|
||||||
video_path VARCHAR(255) NOT NULL,
|
|
||||||
thumbnail_path VARCHAR(255) NOT NULL,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS markers (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
user_id BIGINT NOT NULL,
|
|
||||||
stream_id BIGINT NOT NULL,
|
|
||||||
message VARCHAR(255),
|
|
||||||
timestamp TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
package com.ddf.vodsystem.services.media;
|
|
||||||
|
|
||||||
import com.ddf.vodsystem.dto.ClipOptions;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.mockito.Mock;
|
|
||||||
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
||||||
|
|
||||||
class CompressionServiceTest {
|
|
||||||
@Mock
|
|
||||||
private CommandRunner commandRunner;
|
|
||||||
|
|
||||||
private CompressionService compressionService;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
compressionService = new CompressionService(commandRunner);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void buildCommand_minimalClipOptions_smallCommand() {
|
|
||||||
Path input = Path.of("/tmp/input.mp4");
|
|
||||||
Path output = Path.of("/tmp/output.mp4");
|
|
||||||
ClipOptions options = new ClipOptions();
|
|
||||||
options.setTitle("hello");
|
|
||||||
options.setStartPoint(10.0f);
|
|
||||||
options.setDuration(30.0f);
|
|
||||||
|
|
||||||
List<String> command = compressionService.buildCommand(input, output, options);
|
|
||||||
|
|
||||||
assertThat(command).containsSubsequence("-ss", "10.0");
|
|
||||||
assertThat(command).containsSubsequence("-t", "30.0");
|
|
||||||
assertThat(command).containsSubsequence("-i", input.toAbsolutePath().toString());
|
|
||||||
|
|
||||||
assertThat(command).doesNotContain("-vf");
|
|
||||||
assertThat(command).doesNotContain("-b:v");
|
|
||||||
|
|
||||||
assertThat(command).endsWith(output.toAbsolutePath().toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void buildCommand_hasFileSize_hasCorrectBitrate() {
|
|
||||||
Path input = Path.of("/tmp/input.mp4");
|
|
||||||
Path output = Path.of("/tmp/output.mp4");
|
|
||||||
ClipOptions options = new ClipOptions();
|
|
||||||
|
|
||||||
options.setTitle("hello");
|
|
||||||
options.setStartPoint(10.0f);
|
|
||||||
options.setDuration(30.0f);
|
|
||||||
options.setFileSize(10L);
|
|
||||||
|
|
||||||
float maximumBitrateKbps = (options.getFileSize() * 8 * 1000) / options.getDuration();
|
|
||||||
|
|
||||||
List<String> command = compressionService.buildCommand(input, output, options);
|
|
||||||
|
|
||||||
assertThat(command).contains("-b:v", "-b:a");
|
|
||||||
|
|
||||||
int bvIndex = command.indexOf("-b:v");
|
|
||||||
int baIndex = command.indexOf("-b:a");
|
|
||||||
|
|
||||||
String videoBitrateStr = command.get(bvIndex + 1);
|
|
||||||
String audioBitrateStr = command.get(baIndex + 1);
|
|
||||||
|
|
||||||
assertThat(videoBitrateStr).isNotBlank().endsWith("k");
|
|
||||||
assertThat(audioBitrateStr).isNotBlank().endsWith("k");
|
|
||||||
|
|
||||||
// Strip the trailing "k" so we can compare the numbers, not the strings
|
|
||||||
float videoBitrateKbps = Float.parseFloat(videoBitrateStr.replace("k", ""));
|
|
||||||
float audioBitrateKbps = Float.parseFloat(audioBitrateStr.replace("k", ""));
|
|
||||||
|
|
||||||
float totalBitrateKbps = videoBitrateKbps + audioBitrateKbps;
|
|
||||||
|
|
||||||
assertThat(totalBitrateKbps).isLessThanOrEqualTo(maximumBitrateKbps);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void buildCommand_hasWidthHeight_hasVideoFilters() {
|
|
||||||
Path input = Path.of("/tmp/input.mp4");
|
|
||||||
Path output = Path.of("/tmp/output.mp4");
|
|
||||||
ClipOptions options = new ClipOptions();
|
|
||||||
|
|
||||||
options.setTitle("hello");
|
|
||||||
options.setStartPoint(10.0f);
|
|
||||||
options.setDuration(30.0f);
|
|
||||||
options.setWidth(1920);
|
|
||||||
options.setHeight(1080);
|
|
||||||
|
|
||||||
List<String> command = compressionService.buildCommand(input, output, options);
|
|
||||||
|
|
||||||
assertThat(command).containsSequence("-vf", "scale=1920:1080");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void buildCommand_onlyWidth_aspectRatioKept() {
|
|
||||||
Path input = Path.of("/tmp/input.mp4");
|
|
||||||
Path output = Path.of("/tmp/output.mp4");
|
|
||||||
ClipOptions options = new ClipOptions();
|
|
||||||
|
|
||||||
options.setTitle("hello");
|
|
||||||
options.setStartPoint(10.0f);
|
|
||||||
options.setDuration(30.0f);
|
|
||||||
options.setWidth(1920);
|
|
||||||
|
|
||||||
List<String> command = compressionService.buildCommand(input, output, options);
|
|
||||||
|
|
||||||
assertThat(command).containsSequence("-vf", "scale=1920:-1");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void buildCommand_onlyHeight_aspectRatioKept() {
|
|
||||||
Path input = Path.of("/tmp/input.mp4");
|
|
||||||
Path output = Path.of("/tmp/output.mp4");
|
|
||||||
ClipOptions options = new ClipOptions();
|
|
||||||
|
|
||||||
options.setTitle("hello");
|
|
||||||
options.setStartPoint(10.0f);
|
|
||||||
options.setDuration(30.0f);
|
|
||||||
options.setHeight(1080);
|
|
||||||
|
|
||||||
List<String> command = compressionService.buildCommand(input, output, options);
|
|
||||||
|
|
||||||
assertThat(command).containsSequence("-vf", "scale=-1:1080");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void buildCommand_onlyHasFps_hasNoScaleFilter() {
|
|
||||||
Path input = Path.of("/tmp/input.mp4");
|
|
||||||
Path output = Path.of("/tmp/output.mp4");
|
|
||||||
ClipOptions options = new ClipOptions();
|
|
||||||
|
|
||||||
options.setTitle("hello");
|
|
||||||
options.setStartPoint(10.0f);
|
|
||||||
options.setDuration(30.0f);
|
|
||||||
options.setFps(60f);
|
|
||||||
|
|
||||||
List<String> command = compressionService.buildCommand(input, output, options);
|
|
||||||
|
|
||||||
assertThat(command).containsSequence("-vf", "fps=60.0");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void buildCommand_noInputFile_throwsNullPointer() {
|
|
||||||
Path output = Path.of("/tmp/output.mp4");
|
|
||||||
ClipOptions options = new ClipOptions();
|
|
||||||
|
|
||||||
options.setTitle("hello");
|
|
||||||
options.setStartPoint(10.0f);
|
|
||||||
options.setDuration(30.0f);
|
|
||||||
|
|
||||||
assertThrows(NullPointerException.class, () ->
|
|
||||||
compressionService.buildCommand(null, output, options)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void buildCommand_noOutputFile_throwsNullPointer() {
|
|
||||||
Path input = Path.of("/tmp/output.mp4");
|
|
||||||
ClipOptions options = new ClipOptions();
|
|
||||||
|
|
||||||
options.setTitle("hello");
|
|
||||||
options.setStartPoint(10.0f);
|
|
||||||
options.setDuration(30.0f);
|
|
||||||
|
|
||||||
assertThrows(NullPointerException.class, () ->
|
|
||||||
compressionService.buildCommand(input, null, options)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,312 +0,0 @@
|
|||||||
package com.ddf.vodsystem.services.media;
|
|
||||||
|
|
||||||
import com.ddf.vodsystem.dto.ClipOptions;
|
|
||||||
import com.ddf.vodsystem.dto.CommandOutput;
|
|
||||||
import com.ddf.vodsystem.exceptions.FFMPEGException;
|
|
||||||
import org.junit.jupiter.api.AfterEach;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
import org.mockito.ArgumentCaptor;
|
|
||||||
import org.mockito.Mock;
|
|
||||||
import org.mockito.Mockito;
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.concurrent.ExecutionException;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
||||||
import static org.mockito.ArgumentMatchers.anyList;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
class MetadataServiceTest {
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private CommandRunner commandRunner;
|
|
||||||
|
|
||||||
private MetadataService metadataService;
|
|
||||||
|
|
||||||
private static final Path INPUT = Path.of("/tmp/input.mp4");
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
metadataService = new MetadataService(commandRunner);
|
|
||||||
Thread.interrupted(); // clear any flag leaked from a previous test
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterEach
|
|
||||||
void clearInterruptFlag() {
|
|
||||||
Thread.interrupted();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Wraps a raw ffprobe JSON payload into a successful CommandOutput. */
|
|
||||||
private CommandOutput ffprobeOutput(String json) {
|
|
||||||
CommandOutput output = new CommandOutput();
|
|
||||||
output.addLine(json);
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
private ClipOptions runMetadata() throws Exception {
|
|
||||||
return metadataService.getVideoMetadata(INPUT).get();
|
|
||||||
}
|
|
||||||
private void assertMetadataFailsWith(Class<? extends Throwable> exceptionType, String messageFragment) {
|
|
||||||
assertThatThrownBy(() -> metadataService.getVideoMetadata(INPUT).get())
|
|
||||||
.satisfies(thrown -> {
|
|
||||||
Throwable cause = thrown instanceof ExecutionException ? thrown.getCause() : thrown;
|
|
||||||
assertThat(cause).isInstanceOf(exceptionType);
|
|
||||||
assertThat(cause.getMessage()).contains(messageFragment);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private void assertMetadataFailsWith(String messageFragment) {
|
|
||||||
assertMetadataFailsWith(FFMPEGException.class, messageFragment);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
|
||||||
// Happy path / parsing behavior
|
|
||||||
// ---------------------------------------------------------------
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getVideoMetadata_validJson_parsesAllFields() throws Exception {
|
|
||||||
String json = """
|
|
||||||
{
|
|
||||||
"streams": [
|
|
||||||
{
|
|
||||||
"width": 1920,
|
|
||||||
"height": 1080,
|
|
||||||
"r_frame_rate": "30/1",
|
|
||||||
"duration": "60.000000"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"format": {
|
|
||||||
"size": "10485760",
|
|
||||||
"duration": "60.000000"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
""";
|
|
||||||
when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json));
|
|
||||||
|
|
||||||
ClipOptions options = runMetadata();
|
|
||||||
|
|
||||||
assertThat(options.getStartPoint()).isEqualTo(0f);
|
|
||||||
assertThat(options.getDuration()).isEqualTo(60.0f);
|
|
||||||
assertThat(options.getWidth()).isEqualTo(1920);
|
|
||||||
assertThat(options.getHeight()).isEqualTo(1080);
|
|
||||||
assertThat(options.getFps()).isEqualTo(30.0f);
|
|
||||||
assertThat(options.getFileSize()).isEqualTo(10485760L);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getVideoMetadata_ffprobeCommandIsCorrect() throws Exception {
|
|
||||||
String json = """
|
|
||||||
{
|
|
||||||
"streams": [ { "width": 1920, "height": 1080, "r_frame_rate": "30/1", "duration": "60.0" } ],
|
|
||||||
"format": { "size": "1000", "duration": "60.0" }
|
|
||||||
}
|
|
||||||
""";
|
|
||||||
when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json));
|
|
||||||
|
|
||||||
runMetadata();
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
ArgumentCaptor<List<String>> captor = ArgumentCaptor.forClass(List.class);
|
|
||||||
Mockito.verify(commandRunner).run(captor.capture());
|
|
||||||
List<String> command = captor.getValue();
|
|
||||||
|
|
||||||
assertThat(command).startsWith("ffprobe");
|
|
||||||
assertThat(command).containsSequence("-print_format", "json");
|
|
||||||
assertThat(command).containsSequence("-select_streams", "v:0");
|
|
||||||
assertThat(command).containsSequence("-i", INPUT.toAbsolutePath().toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getVideoMetadata_fractionalFrameRate_computesFps() throws Exception {
|
|
||||||
String json = """
|
|
||||||
{
|
|
||||||
"streams": [ { "width": 1280, "height": 720, "r_frame_rate": "30000/1001", "duration": "10.0" } ],
|
|
||||||
"format": { "size": "500", "duration": "10.0" }
|
|
||||||
}
|
|
||||||
""";
|
|
||||||
when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json));
|
|
||||||
|
|
||||||
ClipOptions options = runMetadata();
|
|
||||||
|
|
||||||
assertThat(options.getFps()).isCloseTo(29.97f, org.assertj.core.data.Offset.offset(0.01f));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getVideoMetadata_integerFrameRate_parsesFps() throws Exception {
|
|
||||||
String json = """
|
|
||||||
{
|
|
||||||
"streams": [ { "width": 1280, "height": 720, "r_frame_rate": "25", "duration": "10.0" } ],
|
|
||||||
"format": { "size": "500", "duration": "10.0" }
|
|
||||||
}
|
|
||||||
""";
|
|
||||||
when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json));
|
|
||||||
|
|
||||||
ClipOptions options = runMetadata();
|
|
||||||
|
|
||||||
assertThat(options.getFps()).isEqualTo(25.0f);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getVideoMetadata_zeroDenominatorFrameRate_fpsIsNull() throws Exception {
|
|
||||||
String json = """
|
|
||||||
{
|
|
||||||
"streams": [ { "width": 1280, "height": 720, "r_frame_rate": "0/0", "duration": "10.0" } ],
|
|
||||||
"format": { "size": "500", "duration": "10.0" }
|
|
||||||
}
|
|
||||||
""";
|
|
||||||
when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json));
|
|
||||||
|
|
||||||
ClipOptions options = runMetadata();
|
|
||||||
|
|
||||||
assertThat(options.getFps()).isNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getVideoMetadata_missingFrameRate_fpsIsNull() throws Exception {
|
|
||||||
String json = """
|
|
||||||
{
|
|
||||||
"streams": [ { "width": 1280, "height": 720, "duration": "10.0" } ],
|
|
||||||
"format": { "size": "500", "duration": "10.0" }
|
|
||||||
}
|
|
||||||
""";
|
|
||||||
when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json));
|
|
||||||
|
|
||||||
ClipOptions options = runMetadata();
|
|
||||||
|
|
||||||
assertThat(options.getFps()).isNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getVideoMetadata_missingStreams_throwsFFMPEGException() throws Exception {
|
|
||||||
String json = """
|
|
||||||
{
|
|
||||||
"streams": [],
|
|
||||||
"format": { "size": "500", "duration": "10.0" }
|
|
||||||
}
|
|
||||||
""";
|
|
||||||
when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json));
|
|
||||||
|
|
||||||
assertMetadataFailsWith("streams missing");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getVideoMetadata_missingWidth_throwsFFMPEGException() throws Exception {
|
|
||||||
String json = """
|
|
||||||
{
|
|
||||||
"streams": [ { "height": 720, "r_frame_rate": "25", "duration": "10.0" } ],
|
|
||||||
"format": { "size": "500", "duration": "10.0" }
|
|
||||||
}
|
|
||||||
""";
|
|
||||||
when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json));
|
|
||||||
|
|
||||||
assertMetadataFailsWith("width missing");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getVideoMetadata_missingHeight_throwsFFMPEGException() throws Exception {
|
|
||||||
String json = """
|
|
||||||
{
|
|
||||||
"streams": [ { "width": 1280, "r_frame_rate": "25", "duration": "10.0" } ],
|
|
||||||
"format": { "size": "500", "duration": "10.0" }
|
|
||||||
}
|
|
||||||
""";
|
|
||||||
when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json));
|
|
||||||
|
|
||||||
assertMetadataFailsWith("height missing");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getVideoMetadata_missingFileSize_throwsFFMPEGException() throws Exception {
|
|
||||||
String json = """
|
|
||||||
{
|
|
||||||
"streams": [ { "width": 1280, "height": 720, "r_frame_rate": "25", "duration": "10.0" } ],
|
|
||||||
"format": { "duration": "10.0" }
|
|
||||||
}
|
|
||||||
""";
|
|
||||||
when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json));
|
|
||||||
|
|
||||||
assertMetadataFailsWith("file size missing");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getVideoMetadata_durationMissingEverywhere_throwsFFMPEGException() throws Exception {
|
|
||||||
String json = """
|
|
||||||
{
|
|
||||||
"streams": [ { "width": 1280, "height": 720, "r_frame_rate": "25" } ],
|
|
||||||
"format": { "size": "500" }
|
|
||||||
}
|
|
||||||
""";
|
|
||||||
when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json));
|
|
||||||
|
|
||||||
assertMetadataFailsWith("duration missing");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getVideoMetadata_commandThrowsIOException_completesExceptionally() throws Exception {
|
|
||||||
when(commandRunner.run(anyList())).thenThrow(new IOException("ffprobe boom"));
|
|
||||||
|
|
||||||
assertMetadataFailsWith(IOException.class, "ffprobe boom");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getVideoMetadata_streamMissingDuration_fallsBackToFormatDuration() throws Exception {
|
|
||||||
// format=duration is requested from ffprobe specifically as a fallback source,
|
|
||||||
// used when the stream itself lacks "duration".
|
|
||||||
String json = """
|
|
||||||
{
|
|
||||||
"streams": [ { "width": 1280, "height": 720, "r_frame_rate": "25" } ],
|
|
||||||
"format": { "size": "500", "duration": "45.5" }
|
|
||||||
}
|
|
||||||
""";
|
|
||||||
when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json));
|
|
||||||
|
|
||||||
ClipOptions options = runMetadata();
|
|
||||||
|
|
||||||
assertThat(options.getDuration()).isEqualTo(45.5f);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getVideoMetadata_largeFileSize_preservesExactByteCount() throws Exception {
|
|
||||||
// fileSize is a Long specifically so byte counts for large files (any real
|
|
||||||
// video is well past the 2^24 float-precision boundary) survive exactly.
|
|
||||||
long exactBytes = 16_777_217L; // smallest integer a 32-bit float cannot represent exactly
|
|
||||||
String json = """
|
|
||||||
{
|
|
||||||
"streams": [ { "width": 1280, "height": 720, "r_frame_rate": "25", "duration": "10.0" } ],
|
|
||||||
"format": { "size": "%d", "duration": "10.0" }
|
|
||||||
}
|
|
||||||
""".formatted(exactBytes);
|
|
||||||
when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json));
|
|
||||||
|
|
||||||
ClipOptions options = runMetadata();
|
|
||||||
|
|
||||||
assertThat(options.getFileSize()).isEqualTo(exactBytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getVideoMetadata_plainIOException_doesNotSetThreadInterruptFlag() throws Exception {
|
|
||||||
when(commandRunner.run(anyList())).thenThrow(new IOException("ffprobe boom"));
|
|
||||||
|
|
||||||
assertMetadataFailsWith(IOException.class, "ffprobe boom");
|
|
||||||
|
|
||||||
assertThat(Thread.currentThread().isInterrupted())
|
|
||||||
.as("a plain IOException must not leave the calling thread's interrupt flag set")
|
|
||||||
.isFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getVideoMetadata_interruptedException_setsThreadInterruptFlag() throws Exception {
|
|
||||||
when(commandRunner.run(anyList())).thenThrow(new InterruptedException("interrupted"));
|
|
||||||
|
|
||||||
assertMetadataFailsWith(InterruptedException.class, "interrupted");
|
|
||||||
|
|
||||||
assertThat(Thread.currentThread().isInterrupted()).isTrue();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user