Compare commits
1 Commits
a051407c1e
...
prod
| Author | SHA1 | Date | |
|---|---|---|---|
| d6c703457c |
@@ -15,6 +15,4 @@ POSTGRES_PASSWORD=mypassword
|
||||
POSTGRES_DB=vodSystem
|
||||
|
||||
FRONTEND_URL=
|
||||
BACKEND_URL=
|
||||
|
||||
STREAM_RING_LENGTH_SECOND=28800
|
||||
BACKEND_URL=
|
||||
142
README.md
142
README.md
@@ -1,119 +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.
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
|
||||
---
|
||||
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.
|
||||
|
||||
## 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
|
||||
- A Google Cloud project with OAuth 2.0 credentials (Client ID and Secret)
|
||||
|
||||
That is all. The backend, frontend, database, and RTMP server all run as containers — no local JDK, Node, or FFmpeg install required.
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
# Future Plans
|
||||
- **Format Handling**
|
||||
- Backend conversion of non-MP4 files via FFMPEG for broader format support
|
||||
- **Input Sources**
|
||||
- Support for local file uploads and YouTube imports
|
||||
- **Testing**
|
||||
- Unit tests, including coverage for FFMPEG-related functionality
|
||||
- **API Documentation**
|
||||
- Comprehensive and maintainable 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: 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 {
|
||||
{
|
||||
"duration": 60.0,
|
||||
"title": "Hello",
|
||||
"description": "my clip"
|
||||
"duration": 60.0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
{
|
||||
"startTime": "2026-07-01T21:09:00Z",
|
||||
"endTime": "2026-07-01T21:12:00Z",
|
||||
"title": "Hello",
|
||||
"description": "my clip"
|
||||
"startTime": "2026-06-21T17:27:00Z",
|
||||
"endTime": "2026-06-21T17:28:00Z"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- WEB_SERVER_URL=http://host.docker.internal:8080
|
||||
ports:
|
||||
|
||||
@@ -32,7 +32,6 @@ services:
|
||||
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
|
||||
@@ -45,8 +44,6 @@ services:
|
||||
context: ./nginx
|
||||
dockerfile: Dockerfile
|
||||
container_name: vod_rtmp
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- WEB_SERVER_URL=http://backend:8080
|
||||
ports:
|
||||
@@ -56,4 +53,4 @@ services:
|
||||
pull_policy: build
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
- backend
|
||||
@@ -6,11 +6,8 @@ import ClipEdit from './pages/ClipEdit';
|
||||
import Home from './pages/Home';
|
||||
import {useEffect} from "react";
|
||||
import MyClips from './pages/MyClips';
|
||||
import MyVods from './pages/MyVods';
|
||||
import VideoPlayer from "./pages/VideoPlayer.tsx";
|
||||
import VodPlayer from "./pages/VodPlayer.tsx";
|
||||
import EditClip from "./pages/EditClip.tsx";
|
||||
import EditVod from "./pages/EditVod.tsx";
|
||||
import Profile from "./pages/Profile.tsx";
|
||||
import { GoogleOAuthProvider } from '@react-oauth/google';
|
||||
|
||||
@@ -29,11 +26,8 @@ function App() {
|
||||
<Route path="/create" element={<ClipUpload />} />
|
||||
<Route path="/create/:id" element={<ClipEdit />} />
|
||||
<Route path="/my-clips" element={<MyClips />} />
|
||||
<Route path="/my-vods" element={<MyVods />} />
|
||||
<Route path="/video/:id" element={<VideoPlayer />} />
|
||||
<Route path="/vod/:id" element={<VodPlayer />} />
|
||||
<Route path="/clips/:id/edit" element={<EditClip />} />
|
||||
<Route path="/vods/:id/edit" element={<EditVod />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import clsx from "clsx";
|
||||
import SidebarButton from "./buttons/SidebarButton.tsx";
|
||||
import { Plus, Film, Home, User as UserIcon, Video } from 'lucide-react';
|
||||
import { Plus, Film, Home, User as UserIcon } from 'lucide-react';
|
||||
import type {User} from "../utils/types.ts";
|
||||
|
||||
type props = {
|
||||
@@ -31,7 +31,6 @@ const Sidebar = ({user, className}: props) => {
|
||||
<Divider />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" />
|
||||
<SidebarButton url="/my-vods" logo={<Video size={18}/>} label="VoDs" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -5,32 +5,15 @@ type props = {
|
||||
dataSend: React.MouseEventHandler<HTMLButtonElement>;
|
||||
handleDownload: React.MouseEventHandler<HTMLButtonElement>;
|
||||
downloadable: boolean;
|
||||
progress: number;
|
||||
uploading: boolean;
|
||||
progress: number
|
||||
}
|
||||
|
||||
const Spinner = () => (
|
||||
<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) => {
|
||||
const ExportWidget = ({dataSend, handleDownload, downloadable, progress}: props) => {
|
||||
return (
|
||||
<div className={"flex flex-col gap-3"}>
|
||||
<BlueButton
|
||||
onClick={dataSend}
|
||||
disabled={uploading}
|
||||
className={uploading ? "opacity-60 cursor-not-allowed flex items-center justify-center gap-2" : ""}
|
||||
>
|
||||
{uploading && <Spinner />}
|
||||
{uploading ? "Uploading..." : "Export"}
|
||||
onClick={dataSend}>
|
||||
Export
|
||||
</BlueButton>
|
||||
|
||||
<div className="h-10 flex items-center">
|
||||
@@ -50,4 +33,4 @@ const ExportWidget = ({dataSend, handleDownload, downloadable, progress, uploadi
|
||||
)
|
||||
}
|
||||
|
||||
export default ExportWidget;
|
||||
export default ExportWidget;
|
||||
@@ -13,21 +13,17 @@ type VideoCardProps = {
|
||||
onEdit?: () => void,
|
||||
onDelete?: () => void,
|
||||
className?: string,
|
||||
mediaApiPath?: string,
|
||||
playerPath?: string,
|
||||
itemLabel?: string,
|
||||
}
|
||||
|
||||
const fallbackThumbnail = "../../../public/default_thumbnail.png";
|
||||
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 [thumbnailAvailable, setThumbnailAvailable] = useState(true);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const thumbnailUrl = `${API_URL}${mediaApiPath}/${id}/thumbnail`;
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
@@ -37,10 +33,10 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
||||
}, [createdAt]);
|
||||
|
||||
useEffect(() => {
|
||||
isThumbnailAvailable(thumbnailUrl)
|
||||
isThumbnailAvailable(id)
|
||||
.then(setThumbnailAvailable)
|
||||
.catch(() => setThumbnailAvailable(false));
|
||||
}, [thumbnailUrl]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
@@ -61,11 +57,11 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
||||
};
|
||||
|
||||
return (
|
||||
<Link to={`${playerPath}/${id}`}>
|
||||
<Link to={"/video/" + id}>
|
||||
<div className={clsx("flex flex-col group cursor-pointer", className)}>
|
||||
<div className="relative overflow-hidden rounded-lg">
|
||||
<img
|
||||
src={thumbnailAvailable ? thumbnailUrl : fallbackThumbnail}
|
||||
src={thumbnailAvailable ? API_URL + `/api/v1/clips/${id}/thumbnail` : fallbackThumbnail}
|
||||
alt="Video Thumbnail"
|
||||
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 && (
|
||||
<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">
|
||||
<button
|
||||
onClick={(e) => stopAndRun(e, onDelete!)}
|
||||
|
||||
@@ -34,7 +34,6 @@ const ClipEdit = () => {
|
||||
});
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [downloadable, setDownloadable] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -60,10 +59,7 @@ const ClipEdit = () => {
|
||||
};
|
||||
|
||||
const sendData = async () => {
|
||||
if (!localFile || uploading) return;
|
||||
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
if (!localFile) return;
|
||||
|
||||
let jobId: string;
|
||||
try {
|
||||
@@ -71,7 +67,6 @@ const ClipEdit = () => {
|
||||
setUploadedId(jobId);
|
||||
} catch (err: unknown) {
|
||||
setError(`Failed to start compression: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
setUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -86,17 +81,14 @@ const ClipEdit = () => {
|
||||
if (job.state === 'FAILED') {
|
||||
setError(`Compression failed: ${job.errorOutput ?? 'Unknown error'}`);
|
||||
clearInterval(intervalId);
|
||||
setUploading(false);
|
||||
} else if (job.isComplete) {
|
||||
clearInterval(intervalId);
|
||||
setDownloadable(true);
|
||||
setUploading(false);
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
setError(`Failed to fetch progress: ${err.message}`);
|
||||
clearInterval(intervalId);
|
||||
setUploading(false);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -174,7 +166,6 @@ const ClipEdit = () => {
|
||||
handleDownload={handleDownload}
|
||||
downloadable={downloadable}
|
||||
progress={progress}
|
||||
uploading={uploading}
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
@@ -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/endpoints.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,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/endpoints";
|
||||
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,310 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, Lock, Maximize, Minimize, Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react";
|
||||
import type { Vod } from "../utils/types";
|
||||
import { AuthError, getVodById, getVodBlob } from "../utils/endpoints.ts";
|
||||
import Box from "../components/Box.tsx";
|
||||
import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts";
|
||||
|
||||
const VodPlayer = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const progressRef = useRef<HTMLDivElement>(null);
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const [videoUrl, setVideoUrl] = useState<string | undefined>(undefined);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notAuthenticated, setNotAuthenticated] = useState(false);
|
||||
const [vod, setVod] = useState<Vod | null>(null);
|
||||
const [timeAgo, setTimeAgo] = useState("");
|
||||
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [bufferedPct, setBufferedPct] = useState(0);
|
||||
const [isWaiting, setIsWaiting] = useState(false);
|
||||
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [controlsVisible, setControlsVisible] = useState(true);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) { setError("VoD ID is required."); return; }
|
||||
|
||||
getVodBlob(id)
|
||||
.then((blob) => setVideoUrl(URL.createObjectURL(blob)))
|
||||
.catch((e) => { if (e instanceof AuthError) setNotAuthenticated(true); });
|
||||
|
||||
getVodById(id)
|
||||
.then(setVod)
|
||||
.catch((e) => {
|
||||
if (e instanceof AuthError) setNotAuthenticated(true);
|
||||
else setError("Failed to load vod details.");
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!vod?.createdAt) return;
|
||||
const update = () => setTimeAgo(dateToTimeAgo(stringToDate(vod.createdAt)));
|
||||
update();
|
||||
const interval = setInterval(update, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [vod]);
|
||||
|
||||
const scheduleHide = useCallback(() => {
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
||||
hideTimerRef.current = setTimeout(() => setControlsVisible(false), 3000);
|
||||
}, []);
|
||||
|
||||
const showControls = useCallback(() => {
|
||||
setControlsVisible(true);
|
||||
scheduleHide();
|
||||
}, [scheduleHide]);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener('fullscreenchange', onChange);
|
||||
return () => document.removeEventListener('fullscreenchange', onChange);
|
||||
}, []);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
document.fullscreenElement
|
||||
? document.exitFullscreen()
|
||||
: containerRef.current.requestFullscreen();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
switch (e.code) {
|
||||
case 'Space': case 'KeyK':
|
||||
e.preventDefault();
|
||||
video.paused ? video.play() : video.pause();
|
||||
showControls();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
video.currentTime = Math.max(0, video.currentTime - 5);
|
||||
showControls();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
video.currentTime = Math.min(video.duration, video.currentTime + 5);
|
||||
showControls();
|
||||
break;
|
||||
case 'KeyM':
|
||||
video.muted = !video.muted;
|
||||
setIsMuted(video.muted);
|
||||
break;
|
||||
case 'KeyF':
|
||||
toggleFullscreen();
|
||||
break;
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [showControls, toggleFullscreen]);
|
||||
|
||||
const seekTo = useCallback((clientX: number) => {
|
||||
const rect = progressRef.current?.getBoundingClientRect();
|
||||
const video = videoRef.current;
|
||||
if (!rect || !video || !duration) return;
|
||||
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
video.currentTime = ratio * duration;
|
||||
setCurrentTime(ratio * duration);
|
||||
}, [duration]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
const onMove = (e: MouseEvent) => seekTo(e.clientX);
|
||||
const onUp = () => setIsDragging(false);
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
}, [isDragging, seekTo]);
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
setCurrentTime(video.currentTime);
|
||||
if (video.buffered.length > 0) {
|
||||
setBufferedPct((video.buffered.end(video.buffered.length - 1) / video.duration) * 100);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = parseFloat(e.target.value);
|
||||
setVolume(v);
|
||||
setIsMuted(v === 0);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.volume = v;
|
||||
videoRef.current.muted = v === 0;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
video.muted = !video.muted;
|
||||
setIsMuted(video.muted);
|
||||
};
|
||||
|
||||
const togglePlay = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
video.paused ? video.play() : video.pause();
|
||||
showControls();
|
||||
};
|
||||
|
||||
const progressPct = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
const effectiveVolume = isMuted ? 0 : volume;
|
||||
const VolumeIcon = effectiveVolume === 0 ? VolumeX : effectiveVolume < 0.5 ? Volume1 : Volume2;
|
||||
|
||||
if (notAuthenticated) {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-4 text-center p-8">
|
||||
<div className="w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<Lock size={24} className="text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<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 VoD.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
Go back
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col p-4 gap-3">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 flex-shrink-0 transition-colors duration-150"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
Back
|
||||
</button>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative bg-black rounded-xl overflow-hidden flex-1 min-h-0 select-none"
|
||||
onMouseMove={showControls}
|
||||
onMouseLeave={() => isPlaying && setControlsVisible(false)}
|
||||
onClick={togglePlay}
|
||||
style={{ cursor: controlsVisible ? 'default' : 'none' }}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="w-full h-full object-contain"
|
||||
src={videoUrl}
|
||||
onPlay={() => { setIsPlaying(true); scheduleHide(); }}
|
||||
onPause={() => {
|
||||
setIsPlaying(false);
|
||||
setControlsVisible(true);
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
||||
}}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={() => { if (videoRef.current) setDuration(videoRef.current.duration); }}
|
||||
onWaiting={() => setIsWaiting(true)}
|
||||
onCanPlay={() => setIsWaiting(false)}
|
||||
onError={(e) => setError(e.currentTarget.error?.message || "An error occurred.")}
|
||||
/>
|
||||
|
||||
{isWaiting && videoUrl && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className={`absolute inset-0 flex flex-col justify-end transition-opacity duration-300 pointer-events-none ${controlsVisible ? 'opacity-100' : 'opacity-0'}`}>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/10 to-transparent" />
|
||||
|
||||
<div
|
||||
className="relative px-4 pb-4 pt-10 pointer-events-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
ref={progressRef}
|
||||
className="relative h-1 rounded-full bg-white/25 cursor-pointer mb-3 group/prog hover:h-1.5 transition-[height] duration-150"
|
||||
onMouseDown={(e) => { setIsDragging(true); seekTo(e.clientX); }}
|
||||
>
|
||||
<div className="absolute inset-y-0 left-0 bg-white/35 rounded-full" style={{ width: `${bufferedPct}%` }} />
|
||||
<div className="absolute inset-y-0 left-0 bg-primary rounded-full" style={{ width: `${progressPct}%` }} />
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow opacity-0 group-hover/prog:opacity-100 transition-opacity"
|
||||
style={{ left: `calc(${progressPct}% - 6px)` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-white">
|
||||
<button onClick={togglePlay} className="hover:text-white/70 transition-colors">
|
||||
{isPlaying
|
||||
? <Pause size={20} fill="currentColor" />
|
||||
: <Play size={20} fill="currentColor" />}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1.5 group/vol">
|
||||
<button onClick={toggleMute} className="hover:text-white/70 transition-colors">
|
||||
<VolumeIcon size={18} />
|
||||
</button>
|
||||
<div className="overflow-hidden w-0 group-hover/vol:w-16 transition-[width] duration-200">
|
||||
<input
|
||||
type="range"
|
||||
min={0} max={1} step={0.02}
|
||||
value={effectiveVolume}
|
||||
onChange={handleVolumeChange}
|
||||
className="w-16 accent-white cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="text-xs font-mono tabular-nums text-white/80">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<button onClick={toggleFullscreen} className="hover:text-white/70 transition-colors">
|
||||
{isFullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-red-500 text-sm mt-3">{error}</div>}
|
||||
{!videoUrl && !error && (
|
||||
<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" />
|
||||
Loading video...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Box className="p-4 flex-shrink-0">
|
||||
<p className="text-xl font-semibold text-gray-900">{vod?.title || "(No Title)"}</p>
|
||||
<p className="text-sm text-gray-500 mt-1">{timeAgo}</p>
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VodPlayer;
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { VideoMetadata, APIResponse, User, Clip, Vod, JobResponse } from "./types.ts";
|
||||
import type { VideoMetadata, APIResponse, User, Clip, JobResponse } from "./types.ts";
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
@@ -144,69 +144,6 @@ const deleteClip = async (id: number): Promise<void> => {
|
||||
if (result.status === 'error') throw new Error(`Failed to delete clip: ${result.message}`);
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const getVodBlob = async (id: string): Promise<Blob> => {
|
||||
const response = await fetch(API_URL + `/api/v1/vods/${id}/media`, { credentials: 'include' });
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) throw new AuthError();
|
||||
throw new Error(`Failed to fetch vod ${id}: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
};
|
||||
|
||||
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}`);
|
||||
};
|
||||
|
||||
const getStreamStatus = async (): Promise<boolean> => {
|
||||
const response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' });
|
||||
if (!response.ok) return false;
|
||||
@@ -214,8 +151,8 @@ const getStreamStatus = async (): Promise<boolean> => {
|
||||
return result.status === 'success' && result.data?.isStreaming === true;
|
||||
};
|
||||
|
||||
const isThumbnailAvailable = async (thumbnailUrl: string): Promise<boolean> => {
|
||||
const response = await fetch(thumbnailUrl, { credentials: 'include' });
|
||||
const isThumbnailAvailable = async (id: number): Promise<boolean> => {
|
||||
const response = await fetch(API_URL + `/api/v1/clips/${id}/thumbnail`, { credentials: 'include' });
|
||||
return response.ok;
|
||||
};
|
||||
|
||||
@@ -230,11 +167,6 @@ export {
|
||||
getVideoBlob,
|
||||
patchClip,
|
||||
deleteClip,
|
||||
getVods,
|
||||
getVodById,
|
||||
getVodBlob,
|
||||
patchVod,
|
||||
deleteVod,
|
||||
getStreamStatus,
|
||||
isThumbnailAvailable,
|
||||
};
|
||||
|
||||
@@ -33,15 +33,6 @@ type Clip = {
|
||||
createdAt: string,
|
||||
}
|
||||
|
||||
type Vod = {
|
||||
id: number,
|
||||
userId: number,
|
||||
title: string,
|
||||
description: string,
|
||||
duration: number,
|
||||
createdAt: string,
|
||||
}
|
||||
|
||||
type JobResponse = {
|
||||
uuid: string,
|
||||
progress: number,
|
||||
@@ -56,6 +47,5 @@ export type {
|
||||
VideoMetadata,
|
||||
User,
|
||||
Clip,
|
||||
Vod,
|
||||
JobResponse
|
||||
}
|
||||
@@ -1,30 +1,27 @@
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/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 {
|
||||
plugins: [react(), tailwindcss(), flowbiteReact()],
|
||||
preview: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
host: true,
|
||||
origin: env.VITE_FRONTEND_URL || "http://0.0.0.0:5173", // use it here
|
||||
allowedHosts: true,
|
||||
proxy: {
|
||||
'/api/v1': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
}
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss(), flowbiteReact()],
|
||||
preview: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
host: true,
|
||||
origin: "http://0.0.0.0:5173",
|
||||
proxy: {
|
||||
'/api/v1': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -20,7 +20,7 @@ rtmp {
|
||||
hls_path /stream;
|
||||
hls_fragment_naming system;
|
||||
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_done ${WEB_SERVER_URL}/api/v1/stream/stop;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/sh
|
||||
|
||||
# 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/nginx.conf
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/clips")
|
||||
@@ -42,8 +43,12 @@ public class ClipController {
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<APIResponse<ClipResponse>> getClipById(@PathVariable Long id) {
|
||||
Clip clip = clipService.getClipById(id);
|
||||
ClipResponse clipDTO = convertToDTO(clip);
|
||||
Optional<Clip> clip = clipService.getClipById(id);
|
||||
if (clip.isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
ClipResponse clipDTO = convertToDTO(clip.get());
|
||||
|
||||
return ResponseEntity.ok(
|
||||
new APIResponse<>(SUCCESS,
|
||||
@@ -66,7 +71,9 @@ public class ClipController {
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<APIResponse<String>> deleteClip(@PathVariable Long id) {
|
||||
clipService.deleteClip(id);
|
||||
if (!clipService.deleteClip(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(
|
||||
new APIResponse<>(
|
||||
|
||||
@@ -94,32 +94,4 @@ public class GlobalExceptionHandler {
|
||||
APIResponse<Void> response = new APIResponse<>(ERROR, "Could not process file", null);
|
||||
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(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(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,63 +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))
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
import com.ddf.vodsystem.controllers.dto.ClipSectionRequest;
|
||||
import com.ddf.vodsystem.controllers.dto.SaveSectionByMarkerRequest;
|
||||
import com.ddf.vodsystem.controllers.dto.SaveSectionRequest;
|
||||
import com.ddf.vodsystem.controllers.dto.UUIDResponse;
|
||||
import com.ddf.vodsystem.dto.Job;
|
||||
@@ -43,26 +42,7 @@ public class MediaController {
|
||||
@PostMapping("/save")
|
||||
public ResponseEntity<APIResponse<UUIDResponse>> save(
|
||||
@RequestBody SaveSectionRequest saveSectionRequest) throws IOException {
|
||||
Job job = mediaService.saveSection(
|
||||
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());
|
||||
Job job = mediaService.saveSection(saveSectionRequest.startTime(), saveSectionRequest.endTime());
|
||||
return ResponseEntity.ok(new APIResponse<>(
|
||||
SUCCESS,
|
||||
"Section saving successfully started",
|
||||
@@ -73,11 +53,7 @@ public class MediaController {
|
||||
@PostMapping("/clip")
|
||||
public ResponseEntity<APIResponse<UUIDResponse>> clip(
|
||||
@RequestBody ClipSectionRequest clipSectionRequest) throws IOException {
|
||||
Job job = mediaService.clip(
|
||||
clipSectionRequest.duration(),
|
||||
clipSectionRequest.title(),
|
||||
clipSectionRequest.description()
|
||||
);
|
||||
Job job = mediaService.clip(clipSectionRequest.duration());
|
||||
return ResponseEntity.ok(new APIResponse<>(
|
||||
SUCCESS,
|
||||
"Clipping successfully started",
|
||||
|
||||
@@ -6,7 +6,6 @@ import com.ddf.vodsystem.entities.User;
|
||||
import com.ddf.vodsystem.exceptions.NotAuthenticated;
|
||||
import com.ddf.vodsystem.services.UserService;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseCookie;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -42,15 +41,14 @@ public class UserController {
|
||||
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<APIResponse<Token>> login(@RequestBody Token token,
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response) {
|
||||
String jwt = userService.login(token.token());
|
||||
|
||||
ResponseCookie cookie = ResponseCookie.from("token", jwt)
|
||||
.httpOnly(true)
|
||||
.maxAge(jwtExpiration / 1000)
|
||||
.sameSite(request.isSecure() ? "None" : "Lax")
|
||||
.secure(request.isSecure())
|
||||
.sameSite("None")
|
||||
.secure(true)
|
||||
.path("/")
|
||||
.build();
|
||||
|
||||
@@ -62,13 +60,12 @@ public class UserController {
|
||||
}
|
||||
|
||||
@PostMapping("/logout")
|
||||
public ResponseEntity<APIResponse<Void>> logout(HttpServletResponse response,
|
||||
HttpServletRequest request) {
|
||||
public ResponseEntity<APIResponse<Void>> logout(HttpServletResponse response) {
|
||||
ResponseCookie cookie = ResponseCookie.from("token", "")
|
||||
.httpOnly(true)
|
||||
.maxAge(0)
|
||||
.sameSite(request.isSecure() ? "None" : "Lax")
|
||||
.secure(request.isSecure())
|
||||
.sameSite("None")
|
||||
.secure(true)
|
||||
.path("/")
|
||||
.build();
|
||||
|
||||
@@ -78,4 +75,4 @@ public class UserController {
|
||||
new APIResponse<>(SUCCESS, "Logged out successfully", null)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,95 +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.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("/{id}/media")
|
||||
public ResponseEntity<Resource> downloadVod(@PathVariable Long id) {
|
||||
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))
|
||||
.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;
|
||||
|
||||
public record ClipSectionRequest(
|
||||
@NotNull float duration,
|
||||
String title,
|
||||
String description
|
||||
@NotNull float duration
|
||||
) {}
|
||||
@@ -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(
|
||||
@NotNull Instant startTime,
|
||||
@NotNull Instant endTime,
|
||||
String title,
|
||||
String description) {}
|
||||
@NotNull Instant endTime) {}
|
||||
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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 Float fileSize;
|
||||
|
||||
@Column(name = "video_path", nullable = false, length = 255)
|
||||
private String videoPath;
|
||||
|
||||
@Column(name = "thumbnail_path", nullable = false, length = 255)
|
||||
private String thumbnailPath;
|
||||
}
|
||||
@@ -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,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
|
||||
* @throws NotAuthenticated if no user session is present
|
||||
* @return a list of clips belonging to the authenticated user.
|
||||
* @throws NotAuthenticated if the user is not authenticated.
|
||||
*/
|
||||
public List<Clip> getClipsByUser() {
|
||||
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
|
||||
* @return the matching {@link Clip} entity
|
||||
* @throws ClipNotFound if no clip with {@code id} exists
|
||||
* @throws NotAuthenticated if the current user does not own the clip
|
||||
* @param id the ID of the clip to retrieve.
|
||||
* @return an Optional containing the Clip if found and accessible, or empty if not found.
|
||||
* @throws NotAuthenticated if the user is not authorized to access the clip.
|
||||
*/
|
||||
public Clip getClipById(Long id) {
|
||||
public Optional<Clip> getClipById(Long id) {
|
||||
Optional<Clip> clip = clipRepository.findById(id);
|
||||
|
||||
if (clip.isEmpty()) {
|
||||
@@ -80,22 +79,21 @@ public class ClipService {
|
||||
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) {
|
||||
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) {
|
||||
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
|
||||
* @throws ClipNotFound if no clip with {@code id} exists
|
||||
* @throws NotAuthenticated if the current user does not own the clip
|
||||
* @param id the ID of the clip to delete.
|
||||
* @return true if the clip was successfully deleted, false if it was not found.
|
||||
* @throws NotAuthenticated if the user is not authorized to delete the clip.
|
||||
*/
|
||||
public void deleteClip(Long id) {
|
||||
Clip clip = getClipById(id);
|
||||
public boolean deleteClip(Long 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);
|
||||
clipRepository.delete(clip);
|
||||
|
||||
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
|
||||
* @return {@code true} if the current user's ID matches the clip's owner; {@code false} if
|
||||
* {@code clip} is null or no user session exists
|
||||
* @param clip the clip to check access for.
|
||||
* @return true if the user is authenticated for the clip, false otherwise.
|
||||
*/
|
||||
public boolean isAuthenticatedForClip(Clip clip) {
|
||||
Optional<User> user = userService.getLoggedInUser();
|
||||
@@ -138,64 +146,79 @@ public class ClipService {
|
||||
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) {
|
||||
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();
|
||||
Path file = directoryService.resolvePath(path);
|
||||
|
||||
if (!Files.exists(file)) {
|
||||
throw new ClipNotFound("Clip file not found");
|
||||
throw new JobNotFound("Clip file not found");
|
||||
}
|
||||
|
||||
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) {
|
||||
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();
|
||||
Path file = directoryService.resolvePath(path);
|
||||
|
||||
if (!Files.exists(file)) {
|
||||
throw new ClipNotFound("Thumbnail file not found");
|
||||
throw new JobNotFound("Thumbnail file not found");
|
||||
}
|
||||
|
||||
return new FileSystemResource(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies a processed clip file into permanent storage, generates its thumbnail, and saves
|
||||
* the clip record to the database.
|
||||
* <p>
|
||||
* Thumbnail generation failure is non-fatal — the error is logged and the clip is saved
|
||||
* without a thumbnail rather than aborting.
|
||||
*
|
||||
* @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
|
||||
* Persists a clip to the database
|
||||
* @param options ClipOptions object of the clip metadata to save to the database. All fields required except for title, description
|
||||
* @param user User to save the clip to
|
||||
* @param videoPath Path of the clip
|
||||
* @param thumbnailPath Path of the thumbnail
|
||||
* @return Clip object saved to the database
|
||||
*/
|
||||
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,
|
||||
String description,
|
||||
User user,
|
||||
@@ -238,25 +261,6 @@ public class ClipService {
|
||||
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) {
|
||||
File clipFile = new File(clip.getVideoPath());
|
||||
File thumbnailFile = new File(clip.getThumbnailPath());
|
||||
|
||||
@@ -1,82 +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);
|
||||
}
|
||||
|
||||
@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.Job;
|
||||
import com.ddf.vodsystem.dto.JobState;
|
||||
import com.ddf.vodsystem.entities.Marker;
|
||||
import com.ddf.vodsystem.entities.User;
|
||||
import com.ddf.vodsystem.exceptions.MarkerNotFound;
|
||||
import com.ddf.vodsystem.exceptions.NotAuthenticated;
|
||||
import com.ddf.vodsystem.services.media.CompressionService;
|
||||
import com.ddf.vodsystem.services.media.StreamActionsService;
|
||||
@@ -18,7 +16,6 @@ import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@Service
|
||||
@@ -33,38 +30,20 @@ public class MediaService {
|
||||
private static final float CLIP_MAX_LENGTH = 180;
|
||||
private static final int HLS_SEGMENT_LENGTH = 3;
|
||||
private final StreamActionsService streamActionsService;
|
||||
private final VodService vodService;
|
||||
private final MarkerService markerService;
|
||||
|
||||
public MediaService(JobRegistryService jobRegistryService,
|
||||
DirectoryService directoryService,
|
||||
CompressionService compressionService,
|
||||
UserService userService,
|
||||
ClipService clipService, StreamActionsService streamActionsService, VodService vodService, MarkerService markerService) {
|
||||
ClipService clipService, StreamActionsService streamActionsService) {
|
||||
this.jobRegistryService = jobRegistryService;
|
||||
this.directoryService = directoryService;
|
||||
this.compressionService = compressionService;
|
||||
this.userService = userService;
|
||||
this.clipService = clipService;
|
||||
this.streamActionsService = streamActionsService;
|
||||
this.vodService = vodService;
|
||||
this.markerService = markerService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @throws InterruptedException if the current thread is interrupted while setting up the job
|
||||
*/
|
||||
public Job compress(MultipartFile file, ClipOptions clipOptions) throws IOException, InterruptedException {
|
||||
Job job = jobRegistryService.generateJob();
|
||||
|
||||
@@ -107,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>
|
||||
* Locates the recorded {@code .ts} HLS segments that overlap {@code [startTime, endTime)}, computes
|
||||
* the trim offset into the first partially-overlapping segment, then delegates extraction and muxing
|
||||
* to {@link StreamActionsService#saveSection}. On success, the output is persisted as a VoD via
|
||||
* {@link VodService#persist}.
|
||||
* Locates the recorded {@code .ts} segments overlapping the requested window, computes the
|
||||
* trim offset into the first (partially-overlapping) segment and the total clip duration,
|
||||
* then delegates the actual extraction to {@link StreamActionsService#saveSection}.
|
||||
*
|
||||
* @param startTime inclusive start of the section to save; must be before {@code endTime}
|
||||
* @param endTime 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
|
||||
* @param startTime the inclusive start of the section to save; must be before {@code endTime}
|
||||
* @param endTime the exclusive end of the section to save
|
||||
* @throws IllegalArgumentException if {@code startTime} is not before {@code endTime},
|
||||
* or if no stream segments exist in the given range
|
||||
* @throws NotAuthenticated if no user is currently authenticated
|
||||
* @throws IOException if reading the stream directory or its segments fails
|
||||
*/
|
||||
public Job saveSection(
|
||||
Instant startTime,
|
||||
Instant endTime,
|
||||
String title,
|
||||
String description) throws IOException {
|
||||
public Job saveSection(Instant startTime, Instant endTime) throws IOException {
|
||||
if (startTime.isAfter(endTime)) {
|
||||
throw new IllegalArgumentException("Start time must be before end time");
|
||||
}
|
||||
@@ -138,125 +108,97 @@ public class MediaService {
|
||||
.orElseThrow(() -> new NotAuthenticated("User is not authenticated"));
|
||||
|
||||
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;
|
||||
|
||||
job.setState(JobState.PROCESSING);
|
||||
Path outputFile = directoryService.getVodsDir(user.getId()).resolve(UUID.randomUUID() + ".mp4");
|
||||
streamActionsService.saveSection(
|
||||
fileSegments,
|
||||
trimOffset,
|
||||
duration,
|
||||
outputFile,
|
||||
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 dispatchSaveJob(job, ctx, duration, outputFile, () ->
|
||||
vodService.persist(
|
||||
title == null ? Instant.now().toString() : title,
|
||||
description == null ? "" : description,
|
||||
user,
|
||||
outputFile,
|
||||
outputFile.getFileName().toString()
|
||||
)
|
||||
);
|
||||
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>
|
||||
* Resolves {@code startMarkerId} and {@code endMarkerId} to their marker timestamps and
|
||||
* delegates to {@link #saveSection(Instant, Instant, String, String)}.
|
||||
* Computes a time window ending at the current instant and beginning {@code duration} seconds
|
||||
* earlier (with millisecond precision), then delegates to {@link #saveSection(Instant, Instant)}.
|
||||
*
|
||||
* @param startMarkerId id of the marker whose timestamp begins the section to save
|
||||
* @param endMarkerId id of the marker whose timestamp ends the section to save
|
||||
* @param title title for the saved VoD; defaults to the current timestamp if {@code null}
|
||||
* @param description description for the saved VoD; defaults to an empty string if {@code null}
|
||||
* @return the {@link Job} tracking the save; state transitions to
|
||||
* {@link JobState#SUCCEEDED} or {@link JobState#FAILED} asynchronously
|
||||
* @throws MarkerNotFound if either marker id does not exist
|
||||
* @throws NotAuthenticated if no user is currently authenticated, or the caller does not
|
||||
* own one of the referenced markers
|
||||
* @throws IllegalArgumentException if the start marker's timestamp is not before the end
|
||||
* marker's, or if no stream segments exist in the given range
|
||||
* @throws IOException if reading the stream directory or its segments fails
|
||||
*/
|
||||
public Job saveSection(
|
||||
Long startMarkerId,
|
||||
Long endMarkerId,
|
||||
String title,
|
||||
String description
|
||||
) throws IOException {
|
||||
Marker start = markerService.getMarkerById(startMarkerId);
|
||||
Marker end = markerService.getMarkerById(endMarkerId);
|
||||
|
||||
return saveSection(start.getTimestamp(), end.getTimestamp(), title, description);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the last {@code duration} seconds of the currently authenticated user's live stream as a clip.
|
||||
* <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]},
|
||||
* @param duration the length of the clip in seconds, measured back from now; must be greater
|
||||
* than {@code 0} and no greater than {@link #CLIP_MAX_LENGTH}. Fractional values
|
||||
* are supported and resolved to the millisecond.
|
||||
* @throws IllegalArgumentException if {@code duration} is not in the range {@code (0, CLIP_MAX_LENGTH]},
|
||||
* or if no stream segments exist in the computed window
|
||||
* @throws NotAuthenticated if no user is currently authenticated
|
||||
* @throws IOException if reading the stream directory or its segments fails
|
||||
*/
|
||||
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) {
|
||||
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();
|
||||
// minusSeconds() only does integer seconds, not float
|
||||
Instant startTime = endTime.minus(Duration.ofMillis((long) (duration * 1000)));
|
||||
|
||||
User user = userService.getLoggedInUser()
|
||||
.orElseThrow(() -> new NotAuthenticated("User is not authenticated"));
|
||||
|
||||
Job job = jobRegistryService.generateJob();
|
||||
SegmentContext ctx = resolveSegments(user, startTime, endTime);
|
||||
Path outputFile = directoryService.getTempOutputDir().resolve(UUID.randomUUID() + ".mp4");
|
||||
Path streamDirectory = directoryService.getStreamDir(user.getStreamKey());
|
||||
List<Path> fileSegments = getSegmentsInRange(streamDirectory, startTime, endTime);
|
||||
|
||||
return dispatchSaveJob(job, ctx, duration, outputFile, () ->
|
||||
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 = getSegmentsInRange(streamDir, startTime, endTime);
|
||||
if (segments.isEmpty()) {
|
||||
if (fileSegments.isEmpty()) {
|
||||
throw new IllegalArgumentException("No stream segments found in the given time range");
|
||||
}
|
||||
long firstMs = 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) {
|
||||
job.setState(JobState.PROCESSING);
|
||||
streamActionsService.saveSection(ctx.segments(), ctx.trimOffset(), duration, outputFile, job.getProgressTracker())
|
||||
long firstSegmentMs = parseTimestampMs(fileSegments.getFirst());
|
||||
float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstSegmentMs) / 1000f);
|
||||
|
||||
Path outputFile = directoryService.getTempOutputDir().resolve(UUID.randomUUID() + ".mp4");
|
||||
streamActionsService.saveSection(fileSegments, trimOffset, duration, outputFile, job.getProgressTracker())
|
||||
.thenRun(() -> {
|
||||
job.setDownload(outputFile);
|
||||
job.setState(JobState.SUCCEEDED);
|
||||
logger.info("Stream save job {} succeeded", job.getUuid());
|
||||
onSuccess.run();
|
||||
}).exceptionally(ex -> {
|
||||
logger.info("Stream clip of job {} succeeded", job.getUuid());
|
||||
clipService.persistClip(
|
||||
Instant.now().toString(),
|
||||
"",
|
||||
user,
|
||||
outputFile,
|
||||
outputFile.getFileName().toString()
|
||||
);
|
||||
}
|
||||
).exceptionally(ex -> {
|
||||
job.setState(JobState.FAILED);
|
||||
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 job;
|
||||
}
|
||||
|
||||
@@ -272,7 +214,7 @@ public class MediaService {
|
||||
return segMs < endMs && (segMs + HLS_SEGMENT_LENGTH * 1000) > startMs;
|
||||
})
|
||||
.sorted(Comparator.comparingLong(this::parseTimestampMs))
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,260 +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);
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
try {
|
||||
thumbnailService.createThumbnail(newVodFile, thumbnailFile, 0.0f);
|
||||
} catch (InterruptedException e) {
|
||||
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);
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
@@ -75,9 +76,13 @@ public class StreamActionsService {
|
||||
progressTracker.markComplete();
|
||||
|
||||
return CompletableFuture.completedFuture(outputFile);
|
||||
} catch (Exception e) {
|
||||
} catch (IOException e) {
|
||||
logger.error("Failed to save section to '{}'", outputFile, e);
|
||||
return CompletableFuture.failedFuture(e);
|
||||
} catch (InterruptedException e) {
|
||||
logger.error("Failed to save section");
|
||||
Thread.currentThread().interrupt();
|
||||
return CompletableFuture.failedFuture(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ spring.application.name=vodSystem
|
||||
# VODs
|
||||
spring.servlet.multipart.max-file-size=2GB
|
||||
spring.servlet.multipart.max-request-size=2GB
|
||||
stream.max-length=${STREAM_RING_LENGTH_SECOND}
|
||||
|
||||
# Database
|
||||
spring.datasource.url=${POSTGRES_URL}
|
||||
|
||||
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');
|
||||
@@ -38,31 +38,4 @@ CREATE TABLE IF NOT EXISTS clips (
|
||||
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 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 FLOAT 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
|
||||
);
|
||||
Reference in New Issue
Block a user