Compare commits
3 Commits
fix/start-
...
40c7a6bae5
| Author | SHA1 | Date | |
|---|---|---|---|
| 40c7a6bae5 | |||
| 76b30214ae | |||
| a87987d9ce |
@@ -4,7 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: gitea.defaoite.dev
|
REGISTRY: gitea.vodsystem.ddns.net
|
||||||
IMAGE_PREFIX: dylan/vod-system
|
IMAGE_PREFIX: dylan/vod-system
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -94,8 +94,6 @@ docker compose -f docker-compose.dev.yml up
|
|||||||
|
|
||||||
Then run the backend and frontend separately in your usual workflow.
|
Then run the backend and frontend separately in your usual workflow.
|
||||||
|
|
||||||
- **Important**: Rootless docker should be used to avoid permission conflicts between the RTMP container and the Spring Boot host
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API Reference
|
## API Reference
|
||||||
|
|||||||
@@ -20,11 +20,12 @@ services:
|
|||||||
context: ./nginx
|
context: ./nginx
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: vod_rtmp
|
container_name: vod_rtmp
|
||||||
network_mode: host
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- WEB_SERVER_URL=http://localhost:8080
|
- WEB_SERVER_URL=http://host.docker.internal:8080
|
||||||
ports:
|
ports:
|
||||||
- "1935:1935"
|
- "1935:1935"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
image: gitea.defaoite.dev/dylan/vod-system-backend:latest
|
image: gitea.vodsystem.ddns.net/dylan/vod-system-backend:latest
|
||||||
container_name: vod_backend
|
container_name: vod_backend
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
@@ -29,7 +29,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: gitea.defaoite.dev/dylan/vod-system-frontend:latest
|
image: gitea.vodsystem.ddns.net/dylan/vod-system-frontend:latest
|
||||||
container_name: vod_frontend
|
container_name: vod_frontend
|
||||||
environment:
|
environment:
|
||||||
VITE_API_URL: ${BACKEND_URL}
|
VITE_API_URL: ${BACKEND_URL}
|
||||||
@@ -38,11 +38,11 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
ports:
|
ports:
|
||||||
- "5173:80"
|
- "5173:5173"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
rtmp:
|
rtmp:
|
||||||
image: gitea.defaoite.dev/dylan/vod-system-rtmp:latest
|
image: gitea.vodsystem.ddns.net/dylan/vod-system-rtmp:latest
|
||||||
container_name: vod_rtmp
|
container_name: vod_rtmp
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
ports:
|
ports:
|
||||||
- "5173:80"
|
- "5173:5173"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
rtmp:
|
rtmp:
|
||||||
|
|||||||
@@ -1,19 +1,9 @@
|
|||||||
# --- Build stage ---
|
FROM node:20-alpine
|
||||||
FROM node:20-alpine AS build
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN --mount=type=cache,target=/root/.npm npm ci
|
RUN --mount=type=cache,target=/root/.npm npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
EXPOSE 5173
|
||||||
|
CMD ["npm", "run", "dev"]
|
||||||
# --- Production stage ---
|
|
||||||
FROM nginx:1.27-alpine AS prod
|
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
|
||||||
COPY frontend_nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
|
|
||||||
# Inject envs at runtime
|
|
||||||
COPY env.sh /docker-entrypoint.d/40-env.sh
|
|
||||||
RUN chmod +x /docker-entrypoint.d/40-env.sh
|
|
||||||
|
|
||||||
EXPOSE 80
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
cat <<EOF > /usr/share/nginx/html/env-config.js
|
|
||||||
window._env_ = {
|
|
||||||
VITE_API_URL: "${VITE_API_URL:-}",
|
|
||||||
VITE_FRONTEND_URL: "${VITE_FRONTEND_URL:-}",
|
|
||||||
VITE_GOOGLE_CLIENT_ID: "${VITE_GOOGLE_CLIENT_ID:-}"
|
|
||||||
};
|
|
||||||
EOF
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name _;
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
error_page 500 502 503 504 /50x.html;
|
|
||||||
location = /50x.html {
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,14 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/png" href="/small_icon.png" />
|
<link rel="icon" type="image/png" href="/small_icon.png" />
|
||||||
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Newsreader:wght@100;200;300;400;500;600;700&family=IBM+Plex+Sans:wght@100;200;300;400;500;600;700&family=IBM+Plex+Mono:wght@100;200;300;400;500;600;700&display=swap" rel="stylesheet">
|
|
||||||
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<script src="/env-config.js"></script>
|
<title>Vite + React + TS</title>
|
||||||
<title>The VoD System</title>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
3507
frontend/package-lock.json
generated
3507
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,21 +8,27 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"postinstall": "flowbite-react patch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-oauth/google": "^0.12.2",
|
"@react-oauth/google": "^0.12.2",
|
||||||
|
"@tailwindcss/vite": "^4.1.7",
|
||||||
|
"axios": "^1.11.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"dotenv": "^16.5.0",
|
||||||
|
"flowbite-react": "^0.11.8",
|
||||||
|
"js-cookie": "^3.0.5",
|
||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
|
"path": "^0.12.7",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-range-slider-input": "^3.2.1",
|
"react-range-slider-input": "^3.2.1",
|
||||||
"react-router-dom": "^7.8.0"
|
"react-router-dom": "^7.8.0",
|
||||||
|
"tailwindcss": "^4.1.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.25.0",
|
"@eslint/js": "^9.25.0",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
|
||||||
"@types/node": "^26.1.1",
|
|
||||||
"@types/react": "^19.1.2",
|
"@types/react": "^19.1.2",
|
||||||
"@types/react-dom": "^19.1.2",
|
"@types/react-dom": "^19.1.2",
|
||||||
"@vitejs/plugin-react": "^4.4.1",
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
@@ -30,9 +36,9 @@
|
|||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.19",
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
"tailwindcss": "^4.3.3",
|
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.30.1",
|
"typescript-eslint": "^8.30.1",
|
||||||
"vite": "^6.3.5"
|
"vite": "^6.3.5",
|
||||||
|
"vite-plugin-mkcert": "^1.17.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
42
frontend/src/App.css
Normal file
42
frontend/src/App.css
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
@@ -7,17 +7,13 @@ import Home from './pages/Home';
|
|||||||
import {useEffect} from "react";
|
import {useEffect} from "react";
|
||||||
import MyClips from './pages/MyClips';
|
import MyClips from './pages/MyClips';
|
||||||
import MyVods from './pages/MyVods';
|
import MyVods from './pages/MyVods';
|
||||||
import MediaPlayer from "./pages/MediaPlayer.tsx";
|
import VideoPlayer from "./pages/VideoPlayer.tsx";
|
||||||
import { getVodById, getVodMediaUrl } from "./utils/api/vods.ts";
|
import VodPlayer from "./pages/VodPlayer.tsx";
|
||||||
import { getClipById, getClipMediaUrl } from "./utils/api/clips.ts";
|
|
||||||
import EditClip from "./pages/EditClip.tsx";
|
import EditClip from "./pages/EditClip.tsx";
|
||||||
import EditVod from "./pages/EditVod.tsx";
|
import EditVod from "./pages/EditVod.tsx";
|
||||||
import Profile from "./pages/Profile.tsx";
|
import Profile from "./pages/Profile.tsx";
|
||||||
import StreamControl from "./pages/StreamControl.tsx";
|
import StreamControl from "./pages/StreamControl.tsx";
|
||||||
import { GoogleOAuthProvider } from '@react-oauth/google';
|
import { GoogleOAuthProvider } from '@react-oauth/google';
|
||||||
import {config} from "./config.ts";
|
|
||||||
import NotFound from "./pages/NotFound.tsx";
|
|
||||||
import AuthProvider from "./auth/AuthProvider.tsx";
|
|
||||||
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -26,8 +22,7 @@ function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GoogleOAuthProvider clientId={config.googleClientId}>
|
<GoogleOAuthProvider clientId={import.meta.env.VITE_GOOGLE_CLIENT_ID}>
|
||||||
<AuthProvider>
|
|
||||||
<Router>
|
<Router>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<MainLayout />}>
|
<Route element={<MainLayout />}>
|
||||||
@@ -36,17 +31,15 @@ function App() {
|
|||||||
<Route path="/create/:id" element={<ClipEdit />} />
|
<Route path="/create/:id" element={<ClipEdit />} />
|
||||||
<Route path="/my-clips" element={<MyClips />} />
|
<Route path="/my-clips" element={<MyClips />} />
|
||||||
<Route path="/my-vods" element={<MyVods />} />
|
<Route path="/my-vods" element={<MyVods />} />
|
||||||
<Route path="/video/:id" element={<MediaPlayer noun="Clip" getMediaUrl={getClipMediaUrl} fetchDetails={getClipById} />} />
|
<Route path="/video/:id" element={<VideoPlayer />} />
|
||||||
<Route path="/vod/:id" element={<MediaPlayer noun="VoD" getMediaUrl={getVodMediaUrl} fetchDetails={getVodById} />} />
|
<Route path="/vod/:id" element={<VodPlayer />} />
|
||||||
<Route path="/clips/:id/edit" element={<EditClip />} />
|
<Route path="/clips/:id/edit" element={<EditClip />} />
|
||||||
<Route path="/vods/:id/edit" element={<EditVod />} />
|
<Route path="/vods/:id/edit" element={<EditVod />} />
|
||||||
<Route path="/profile" element={<Profile />} />
|
<Route path="/profile" element={<Profile />} />
|
||||||
<Route path="/stream" element={<StreamControl />} />
|
<Route path="/stream" element={<StreamControl />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
</AuthProvider>
|
|
||||||
</ GoogleOAuthProvider>
|
</ GoogleOAuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -1,20 +0,0 @@
|
|||||||
import { createContext } from "react";
|
|
||||||
import type { User } from "../utils/types.ts";
|
|
||||||
|
|
||||||
type AuthContextValue = {
|
|
||||||
/** The currently authenticated user, or null when logged out. */
|
|
||||||
user: User | null;
|
|
||||||
/** True until the initial "who am I" request has resolved. */
|
|
||||||
loading: boolean;
|
|
||||||
/** Exchange a Google credential for a session, then load the user. */
|
|
||||||
login: (googleCredential: string) => Promise<void>;
|
|
||||||
/** End the session and clear the user. */
|
|
||||||
logout: () => Promise<void>;
|
|
||||||
/** Re-fetch the current user (e.g. after a profile change). */
|
|
||||||
refresh: () => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
|
||||||
|
|
||||||
export { AuthContext };
|
|
||||||
export type { AuthContextValue };
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import type { User } from "../utils/types.ts";
|
|
||||||
import { login as apiLogin, logout as apiLogout, getUser } from "../utils/api/users.ts";
|
|
||||||
import { AuthContext } from "./AuthContext.ts";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Owns the single source of truth for the authenticated user. Everything that
|
|
||||||
* needs to know who is logged in — the layout, the top bar, protected pages —
|
|
||||||
* reads it through the `useAuth` hook rather than fetching the user itself.
|
|
||||||
*/
|
|
||||||
const AuthProvider = ({ children }: { children: ReactNode }) => {
|
|
||||||
const [user, setUser] = useState<User | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setUser(await getUser());
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch user:", error);
|
|
||||||
setUser(null);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
refresh().finally(() => setLoading(false));
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
const login = useCallback(async (googleCredential: string) => {
|
|
||||||
await apiLogin(googleCredential);
|
|
||||||
await refresh();
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
|
||||||
await apiLogout();
|
|
||||||
setUser(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthContext.Provider value={{ user, loading, login, logout, refresh }}>
|
|
||||||
{children}
|
|
||||||
</AuthContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AuthProvider;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { useContext } from "react";
|
|
||||||
import { AuthContext } from "./AuthContext.ts";
|
|
||||||
import type { AuthContextValue } from "./AuthContext.ts";
|
|
||||||
|
|
||||||
/** Access the centralised auth state. Must be used within an <AuthProvider>. */
|
|
||||||
const useAuth = (): AuthContextValue => {
|
|
||||||
const context = useContext(AuthContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useAuth must be used within an AuthProvider");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|
||||||
export { useAuth };
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -7,7 +6,7 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Box = ({ children, className }: Props) => (
|
const Box = ({ children, className }: Props) => (
|
||||||
<div className={clsx("bg-card border-hairline shadow-sm rounded-lg", className)}>
|
<div className={clsx("bg-white border border-gray-200 shadow-sm rounded-lg", className)}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ type DropdownItemProps = {
|
|||||||
|
|
||||||
const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => (
|
const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => (
|
||||||
<li
|
<li
|
||||||
className={clsx("cursor-pointer hover:bg-hover px-3 py-2 text-sm rounded-md", className)}
|
className={clsx("cursor-pointer hover:bg-gray-50 px-3 py-2 text-sm text-gray-700", className)}
|
||||||
onClick={() => onClick(item)}
|
onClick={() => onClick(item)}
|
||||||
>
|
>
|
||||||
{item}
|
{item}
|
||||||
@@ -40,12 +40,12 @@ const Dropdown = ({ label, children, className }: DropdownProps) => {
|
|||||||
<div className={clsx(className, "relative inline-block text-left")} ref={ref}>
|
<div className={clsx(className, "relative inline-block text-left")} ref={ref}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(v => !v)}
|
onClick={() => setIsOpen(v => !v)}
|
||||||
className="inline-flex items-center gap-1 px-3 py-2 text-sm transition-colors duration-150"
|
className="inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 transition-colors duration-150"
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<ul className="absolute right-0 mt-1 w-36 origin-top-right rounded-lg bg-background border border-hairline shadow-md z-50 py-1 overflow-hidden">
|
<ul className="absolute right-0 mt-1 w-36 origin-top-right rounded-lg bg-white border border-gray-200 shadow-md z-50 py-1 overflow-hidden">
|
||||||
{children}
|
{children}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ type Props = {
|
|||||||
|
|
||||||
const Selector = ({ children, label }: Props) => (
|
const Selector = ({ children, label }: Props) => (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="w-full text-sm text-text-secondary">{label}</label>
|
<label className="w-full text-sm text-gray-600">{label}</label>
|
||||||
<div className="w-px h-5 text-text-secondary mx-2" />
|
<div className="w-px h-5 bg-gray-200 mx-2" />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,51 +1,50 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import SidebarButton from "./buttons/SidebarButton.tsx";
|
import SidebarButton from "./buttons/SidebarButton.tsx";
|
||||||
import { Plus, Film, Home, User as UserIcon, Video, Radio } from 'lucide-react';
|
import { Plus, Film, Home, User as UserIcon, Video, Radio } from 'lucide-react';
|
||||||
import { useAuth } from "../auth/useAuth.ts";
|
import type {User} from "../utils/types.ts";
|
||||||
|
|
||||||
type props = {
|
type props = {
|
||||||
|
user: User | null;
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const Divider = () => <hr className="w-full my-1.5 mx-2 border-hover" />;
|
const Divider = () => <hr className="my-1.5 mx-2 border-gray-100" />;
|
||||||
|
|
||||||
const Sidebar = ({className}: props) => {
|
|
||||||
const { user } = useAuth();
|
|
||||||
|
|
||||||
|
const Sidebar = ({user, className}: props) => {
|
||||||
return (
|
return (
|
||||||
<aside className={clsx("h-screen flex flex-col bg-sidebar border-r border-hairline p-2 items-center gap-1", className)}>
|
<aside className={clsx("h-screen flex flex-col bg-white border-r border-gray-200", className)}>
|
||||||
<h2 className={"font-heading text-2xl mt-1"}>The VoD System</h2>
|
<div className="px-4 pt-5 pb-3">
|
||||||
|
<img
|
||||||
<nav className="flex flex-col flex-1 items-center w-3/4">
|
className="w-36 mx-auto"
|
||||||
<Divider />
|
src="../../public/logo.png"
|
||||||
|
alt="VoD System Logo"
|
||||||
<div className="flex flex-col gap-0.5 w-full">
|
/>
|
||||||
|
</div>
|
||||||
|
<nav className="flex flex-col flex-1 px-2 pb-4">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
<SidebarButton url="/" logo={<Home size={18}/>} label="Home" />
|
<SidebarButton url="/" logo={<Home size={18}/>} label="Home" />
|
||||||
<SidebarButton url="/create" logo={<Plus size={18}/>} label="Create Clip" />
|
<SidebarButton url="/create" logo={<Plus size={18}/>} label="Create Clip" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{user && (
|
{user && (
|
||||||
<div className="flex flex-col gap-0.5 w-full">
|
<>
|
||||||
<div>
|
<Divider />
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
<SidebarButton url="/stream" logo={<Radio size={18}/>} label="Stream" />
|
<SidebarButton url="/stream" logo={<Radio size={18}/>} label="Stream" />
|
||||||
<SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" />
|
<SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" />
|
||||||
<SidebarButton url="/my-vods" logo={<Video size={18}/>} label="VoDs" />
|
<SidebarButton url="/my-vods" logo={<Video size={18}/>} label="VoDs" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
<div className={"w-full"}>
|
|
||||||
{user && (
|
{user && (
|
||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
<SidebarButton url="/profile" logo={<UserIcon size={18}/>} label="Profile" />
|
<SidebarButton url="/profile" logo={<UserIcon size={18}/>} label="Profile" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import { Menu, X } from 'lucide-react';
|
import { Menu, X } from 'lucide-react';
|
||||||
|
import { login, logout } from "../utils/api/users.ts";
|
||||||
import { Dropdown, DropdownItem } from "./Dropdown.tsx";
|
import { Dropdown, DropdownItem } from "./Dropdown.tsx";
|
||||||
import { GoogleLogin } from '@react-oauth/google';
|
import { GoogleLogin } from '@react-oauth/google';
|
||||||
|
|
||||||
|
import type { User } from "../utils/types.ts";
|
||||||
import type { CredentialResponse } from '@react-oauth/google';
|
import type { CredentialResponse } from '@react-oauth/google';
|
||||||
|
|
||||||
import MenuButton from "./buttons/MenuButton.tsx";
|
import MenuButton from "./buttons/MenuButton.tsx";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {useNavigate} from "react-router-dom";
|
import {useNavigate} from "react-router-dom";
|
||||||
import { useAuth } from "../auth/useAuth.ts";
|
|
||||||
|
|
||||||
|
|
||||||
type props = {
|
type props = {
|
||||||
sidebarToggled: boolean;
|
sidebarToggled: boolean;
|
||||||
setSidebarToggled: (toggled: boolean) => void;
|
setSidebarToggled: (toggled: boolean) => void;
|
||||||
|
user: User | null;
|
||||||
|
fetchUser: () => void;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -20,11 +23,12 @@ type props = {
|
|||||||
const Topbar = ({
|
const Topbar = ({
|
||||||
sidebarToggled,
|
sidebarToggled,
|
||||||
setSidebarToggled,
|
setSidebarToggled,
|
||||||
|
user,
|
||||||
|
fetchUser,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
className}: props) => {
|
className}: props) => {
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, login, logout } = useAuth();
|
|
||||||
|
|
||||||
const handleLogin = (response: CredentialResponse) => {
|
const handleLogin = (response: CredentialResponse) => {
|
||||||
if (!response.credential) {
|
if (!response.credential) {
|
||||||
@@ -33,6 +37,10 @@ const Topbar = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
login(response.credential)
|
login(response.credential)
|
||||||
|
.then(() => {
|
||||||
|
fetchUser();
|
||||||
|
navigate(0);
|
||||||
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("Login failed:", error);
|
console.error("Login failed:", error);
|
||||||
});
|
});
|
||||||
@@ -40,20 +48,23 @@ const Topbar = ({
|
|||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout()
|
logout()
|
||||||
.then(() => navigate("/"))
|
.then(() => {
|
||||||
|
fetchUser();
|
||||||
|
navigate("/");
|
||||||
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("Logout failed:", error);
|
console.error("Logout failed:", error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx(className, "flex justify-between items-center px-4 py-2 bg-sidebar border-b border-hairline")}>
|
<div className={clsx(className, "flex justify-between items-center px-4 py-2 bg-white border-b border-gray-200")}>
|
||||||
<MenuButton onClick={() => setSidebarToggled(!sidebarToggled)}>
|
<MenuButton onClick={() => setSidebarToggled(!sidebarToggled)}>
|
||||||
{sidebarToggled ? <Menu size={24}/> : <X size={24}/>}
|
{sidebarToggled ? <Menu size={24}/> : <X size={24}/>}
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
|
|
||||||
{ user ? (
|
{ user ? (
|
||||||
<div className={"hover:bg-hover rounded-lg p-0.5"}>
|
<div>
|
||||||
<img
|
<img
|
||||||
className={"w-8 h-8 rounded-full inline-block"}
|
className={"w-8 h-8 rounded-full inline-block"}
|
||||||
src={user.profilePictureUrl}
|
src={user.profilePictureUrl}
|
||||||
@@ -65,8 +76,8 @@ const Topbar = ({
|
|||||||
{user.name}
|
{user.name}
|
||||||
{isStreaming && (
|
{isStreaming && (
|
||||||
<span className="relative flex h-2 w-2">
|
<span className="relative flex h-2 w-2">
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-live opacity-75" />
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-500 opacity-75" />
|
||||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-live" />
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -75,7 +86,7 @@ const Topbar = ({
|
|||||||
onClick={() => navigate("/profile")} />
|
onClick={() => navigate("/profile")} />
|
||||||
<DropdownItem item="Logout"
|
<DropdownItem item="Logout"
|
||||||
onClick={() => handleLogout()}
|
onClick={() => handleLogout()}
|
||||||
className={"text-error font-medium"} />
|
className={"text-red-500 font-medium"} />
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
) :
|
) :
|
||||||
|
|||||||
14
frontend/src/components/buttons/BlueButton.tsx
Normal file
14
frontend/src/components/buttons/BlueButton.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||||
|
|
||||||
|
const BlueButton: React.FC<Props> = ({ className = "", ...props }) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`bg-primary text-white font-medium rounded-lg hover:bg-primary-pressed transition-colors duration-150 h-10 px-4 ${className}`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BlueButton;
|
||||||
@@ -6,7 +6,7 @@ type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;
|
|||||||
const MenuButton: React.FC<Props> = ({ className = "", ...props }) => {
|
const MenuButton: React.FC<Props> = ({ className = "", ...props }) => {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={clsx("p-2 rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors duration-150", className)}
|
className={clsx("p-2 rounded-md text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors duration-150", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;
|
|
||||||
|
|
||||||
const PrimaryButton: React.FC<Props> = ({ className = "", ...props }) => {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={`bg-terracotta text-on-accent font-body text-sm rounded-lg hover:bg-terracotta-hover disabled:bg-terracotta-disabled transition-colors duration-150 h-10 px-4 ${className}`}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PrimaryButton;
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import MenuButton from "./MenuButton.tsx";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -13,15 +13,9 @@ const SidebarButton = ({url, logo, label}: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Link className="w-full" to={url}>
|
<Link className="w-full" to={url}>
|
||||||
<button className={`flex items-center gap-2.5 w-full text-sm
|
<MenuButton className={`flex items-center gap-2.5 w-full px-3 py-2 text-sm font-medium rounded-md ${isActive ? "bg-gray-100 text-gray-900" : "text-gray-600"}`}>
|
||||||
font-body p-2 hover:bg-hover hover:text-text-strong
|
|
||||||
rounded-md
|
|
||||||
${isActive ?
|
|
||||||
"bg-card text-text-strong font-medium shadow-sm"
|
|
||||||
:
|
|
||||||
"text-text-primary"}`}>
|
|
||||||
{logo}{label}
|
{logo}{label}
|
||||||
</button>
|
</MenuButton>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ type Props = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const inputClass = "border border-hairline bg-card rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors";
|
const inputClass = "border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors";
|
||||||
|
|
||||||
export default function ConfigBox({ setMetadata, className }: Props) {
|
export default function ConfigBox({ setMetadata, className }: Props) {
|
||||||
const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
@@ -26,7 +26,7 @@ export default function ConfigBox({ setMetadata, className }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx("flex flex-col gap-3 p-6", className)}>
|
<div className={clsx("flex flex-col gap-3 p-6", className)}>
|
||||||
<h2 className="text-sm text-text-primary font-semibold mb-1">Export Settings</h2>
|
<h2 className="text-sm font-semibold text-gray-800 mb-1">Export Settings</h2>
|
||||||
|
|
||||||
<Selector label="Resolution">
|
<Selector label="Resolution">
|
||||||
<select id="resolution" name="resolution" defaultValue="1280,720"
|
<select id="resolution" name="resolution" defaultValue="1280,720"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import PrimaryButton from "../buttons/PrimaryButton.tsx";
|
import BlueButton from "../buttons/BlueButton.tsx";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
type props = {
|
type props = {
|
||||||
@@ -24,24 +24,24 @@ const Spinner = () => (
|
|||||||
const ExportWidget = ({dataSend, handleDownload, downloadable, progress, uploading}: props) => {
|
const ExportWidget = ({dataSend, handleDownload, downloadable, progress, uploading}: props) => {
|
||||||
return (
|
return (
|
||||||
<div className={"flex flex-col gap-3"}>
|
<div className={"flex flex-col gap-3"}>
|
||||||
<PrimaryButton
|
<BlueButton
|
||||||
onClick={dataSend}
|
onClick={dataSend}
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
className={uploading ? "opacity-60 cursor-not-allowed flex items-center justify-center gap-2" : ""}
|
className={uploading ? "opacity-60 cursor-not-allowed flex items-center justify-center gap-2" : ""}
|
||||||
>
|
>
|
||||||
{uploading && <Spinner />}
|
{uploading && <Spinner />}
|
||||||
{uploading ? "Uploading..." : "Export"}
|
{uploading ? "Uploading..." : "Export"}
|
||||||
</PrimaryButton>
|
</BlueButton>
|
||||||
|
|
||||||
<div className="h-10 flex items-center">
|
<div className="h-10 flex items-center">
|
||||||
{ downloadable ?
|
{ downloadable ?
|
||||||
(<PrimaryButton className="w-full" onClick={handleDownload}>
|
(<BlueButton className="w-full" onClick={handleDownload}>
|
||||||
Download
|
Download
|
||||||
</PrimaryButton>)
|
</BlueButton>)
|
||||||
:(
|
:(
|
||||||
<progress
|
<progress
|
||||||
value={progress}
|
value={progress}
|
||||||
className="w-full h-1.5 rounded bg-fields">
|
className="w-full h-1.5 rounded bg-gray-200">
|
||||||
</progress>
|
</progress>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,15 +9,12 @@ type Props = {
|
|||||||
|
|
||||||
const MetadataBox = ({ setMetadata, className }: Props) => (
|
const MetadataBox = ({ setMetadata, className }: Props) => (
|
||||||
<div className={clsx("flex flex-col content-between p-6 gap-2", className)}>
|
<div className={clsx("flex flex-col content-between p-6 gap-2", className)}>
|
||||||
<p className="w-full font-body text-text-strong font-bold text-sm">
|
<p className="w-full font-semibold text-gray-800 text-sm">Title</p>
|
||||||
Title <span className="text-error">*</span>
|
|
||||||
</p>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
|
||||||
placeholder="Enter title"
|
placeholder="Enter title"
|
||||||
onChange={(e) => setMetadata(prev => ({ ...prev, title: e.target.value }))}
|
onChange={(e) => setMetadata(prev => ({ ...prev, title: e.target.value }))}
|
||||||
className="border border-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors"
|
className="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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -62,11 +62,11 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={togglePlay}
|
onClick={togglePlay}
|
||||||
className="hover:bg-hover rounded-full p-1.5 transition-colors duration-150 text-text-body"
|
className="hover:bg-gray-100 rounded-full p-1.5 transition-colors duration-150 text-gray-700"
|
||||||
>
|
>
|
||||||
{isPlaying ? <Pause size={24} /> : <Play size={24} />}
|
{isPlaying ? <Pause size={24} /> : <Play size={24} />}
|
||||||
</button>
|
</button>
|
||||||
<span className="font-data text-sm text-text-secondary tabular-nums">
|
<span>
|
||||||
{formatTime(video?.currentTime ?? 0)} / {formatTime(videoMetadata.duration ?? 0)}
|
{formatTime(video?.currentTime ?? 0)} / {formatTime(videoMetadata.duration ?? 0)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
|
import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { isThumbnailAvailable } from "../../utils/api/client.ts";
|
||||||
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
|
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
|
||||||
import {config} from "../../config.ts";
|
|
||||||
|
|
||||||
type VideoCardProps = {
|
type VideoCardProps = {
|
||||||
id: number,
|
id: number,
|
||||||
@@ -18,12 +18,12 @@ type VideoCardProps = {
|
|||||||
itemLabel?: string,
|
itemLabel?: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackThumbnail = "public/default_thumbnail.png";
|
const fallbackThumbnail = "../../../public/default_thumbnail.png";
|
||||||
const API_URL = config.apiUrl;
|
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, mediaApiPath = '/api/v1/clips', playerPath = '/video', itemLabel = 'clip' }: VideoCardProps) => {
|
||||||
const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt)));
|
const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt)));
|
||||||
const [imgFailed, setImgFailed] = useState(false);
|
const [thumbnailAvailable, setThumbnailAvailable] = useState(true);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -36,6 +36,12 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [createdAt]);
|
}, [createdAt]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isThumbnailAvailable(thumbnailUrl)
|
||||||
|
.then(setThumbnailAvailable)
|
||||||
|
.catch(() => setThumbnailAvailable(false));
|
||||||
|
}, [thumbnailUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuOpen) return;
|
if (!menuOpen) return;
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
@@ -59,13 +65,12 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
|||||||
<div className={clsx("flex flex-col group cursor-pointer", className)}>
|
<div className={clsx("flex flex-col group cursor-pointer", className)}>
|
||||||
<div className="relative overflow-hidden rounded-lg">
|
<div className="relative overflow-hidden rounded-lg">
|
||||||
<img
|
<img
|
||||||
src={imgFailed ? fallbackThumbnail : thumbnailUrl}
|
src={thumbnailAvailable ? thumbnailUrl : fallbackThumbnail}
|
||||||
onError={() => setImgFailed(true)}
|
|
||||||
alt="Video Thumbnail"
|
alt="Video Thumbnail"
|
||||||
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200"
|
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span className="absolute top-1.5 left-1.5 bg-text-primary/75 text-on-dark px-1.5 py-0.5 rounded text-xs font-data pointer-events-none">
|
<span className="absolute top-1.5 left-1.5 bg-black/70 text-white px-1.5 py-0.5 rounded text-xs font-medium pointer-events-none">
|
||||||
{formatTime(duration)}
|
{formatTime(duration)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -77,18 +82,18 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => stopAndRun(e, () => { setMenuOpen(v => !v); setConfirmDelete(false); })}
|
onClick={(e) => stopAndRun(e, () => { setMenuOpen(v => !v); setConfirmDelete(false); })}
|
||||||
className="bg-text-primary/75 hover:bg-text-primary/90 text-on-dark rounded p-0.5 transition-colors"
|
className="bg-black/70 hover:bg-black/90 text-white rounded p-0.5 transition-colors"
|
||||||
>
|
>
|
||||||
<MoreVertical size={14} />
|
<MoreVertical size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<div className="absolute right-0 top-7 w-32 bg-card rounded-lg border border-hairline shadow-md z-50 py-1 overflow-hidden">
|
<div className="absolute right-0 top-7 w-32 bg-white rounded-lg border border-gray-200 shadow-md z-50 py-1 overflow-hidden">
|
||||||
{!confirmDelete && (<>
|
{!confirmDelete && (<>
|
||||||
{onEdit && (
|
{onEdit && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => stopAndRun(e, onEdit)}
|
onClick={(e) => stopAndRun(e, onEdit)}
|
||||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-text-body hover:bg-hover"
|
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
<Pencil size={13} /> Edit
|
<Pencil size={13} /> Edit
|
||||||
</button>
|
</button>
|
||||||
@@ -96,7 +101,7 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
|||||||
{onDelete && (
|
{onDelete && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => stopAndRun(e, () => setConfirmDelete(true))}
|
onClick={(e) => stopAndRun(e, () => setConfirmDelete(true))}
|
||||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-error hover:bg-hover"
|
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-red-500 hover:bg-red-50"
|
||||||
>
|
>
|
||||||
<Trash2 size={13} /> Delete
|
<Trash2 size={13} /> Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -104,17 +109,17 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
|||||||
</>)}
|
</>)}
|
||||||
{confirmDelete && (
|
{confirmDelete && (
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<p className="text-xs text-text-secondary mb-2">Delete this {itemLabel}?</p>
|
<p className="text-xs text-gray-600 mb-2">Delete this {itemLabel}?</p>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => stopAndRun(e, onDelete!)}
|
onClick={(e) => stopAndRun(e, onDelete!)}
|
||||||
className="flex-1 text-xs bg-error text-on-accent rounded px-2 py-1 hover:bg-terracotta-hover transition-colors"
|
className="flex-1 text-xs bg-red-500 text-white rounded px-2 py-1 hover:bg-red-600 transition-colors"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => stopAndRun(e, () => setConfirmDelete(false))}
|
onClick={(e) => stopAndRun(e, () => setConfirmDelete(false))}
|
||||||
className="flex-1 text-xs bg-fields text-text-secondary rounded px-2 py-1 hover:bg-hover transition-colors"
|
className="flex-1 text-xs bg-gray-100 text-gray-600 rounded px-2 py-1 hover:bg-gray-200 transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -128,10 +133,10 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col p-2">
|
<div className="flex flex-col p-2">
|
||||||
<p className="font-body text-text-strong text-sm leading-snug line-clamp-2">
|
<p className="font-medium text-gray-900 text-sm leading-snug line-clamp-2">
|
||||||
{title === "" ? "(No Title)" : title}
|
{title === "" ? "(No Title)" : title}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted text-xs mt-0.5">{timeAgo}</p>
|
<p className="text-gray-500 text-xs mt-0.5">{timeAgo}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
export const config = {
|
|
||||||
apiUrl: window._env_?.VITE_API_URL ?? import.meta.env.VITE_API_URL ?? '',
|
|
||||||
frontendUrl: window._env_?.VITE_FRONTEND_URL ?? import.meta.env.VITE_FRONTEND_URL ?? '',
|
|
||||||
googleClientId: window._env_?.VITE_GOOGLE_CLIENT_ID ?? import.meta.env.VITE_GOOGLE_CLIENT_ID ?? '',
|
|
||||||
};
|
|
||||||
@@ -1,51 +1,37 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@plugin "flowbite-react/plugin/tailwindcss";
|
||||||
|
@source "../.flowbite-react/class-list.json";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
/* ── Fonts ───────────────────────────────────────────────────────────── */
|
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
--font-body: "IBM Plex Sans", system-ui, sans-serif;
|
|
||||||
--font-data: "IBM Plex Mono", monospace;
|
|
||||||
|
|
||||||
/* ── Surfaces ────────────────────────────────────────────────────────── */
|
/* Breakpoints */
|
||||||
--color-background: #F7F4EE;
|
--breakpoint-3xl: 1920px;
|
||||||
--color-sidebar: #F2EEE6;
|
|
||||||
--color-card: #FDFBF7;
|
|
||||||
--color-fields: #F4F0E8;
|
|
||||||
--color-dropzone: #F9F6EF;
|
|
||||||
|
|
||||||
/* ── Ink ─────────────────────────────────────────────────────────────── */
|
--color-primary: oklch(0.55 0.21 254);
|
||||||
--color-text-primary: #2B2620;
|
--color-primary-pressed: oklch(0.45 0.21 254);
|
||||||
--color-text-strong: #35302A;
|
--color-secondary: oklch(0.94 0.01 250);
|
||||||
--color-text-body: #554D44;
|
--color-accent: oklch(0.68 0.20 288);
|
||||||
--color-text-secondary: #6E6559;
|
--color-text: oklch(0.17 0.01 270);
|
||||||
--color-muted: #9A9084;
|
--color-background: oklch(0.97 0.005 250);
|
||||||
--color-faint-mono: #A79C8E;
|
--color-surface: oklch(1 0 0);
|
||||||
|
--color-border: oklch(0.91 0.008 250);
|
||||||
|
|
||||||
/* ── Accents ─────────────────────────────────────────────────────────── */
|
/* Easing */
|
||||||
--color-terracotta: #A85A38;
|
--ease-fluid: cubic-bezier(0.3, 0, 0, 1);
|
||||||
--color-terracotta-hover: #94492A;
|
--ease-snappy: cubic-bezier(0.2, 0, 0, 1);
|
||||||
--color-terracotta-light: #C87A52;
|
}
|
||||||
--color-terracotta-disabled: #C9AE9C;
|
|
||||||
--color-olive: #6E7A50;
|
|
||||||
|
|
||||||
/* ── Status ──────────────────────────────────────────────────────────── */
|
#range-slider .range-slider__range {
|
||||||
--color-live: #B5432E;
|
background: var(--color-primary);
|
||||||
--color-error: #B5432E;
|
}
|
||||||
|
|
||||||
/* ── Ink on dark / accent surfaces ───────────────────────────────────── */
|
#range-slider .range-slider__thumb {
|
||||||
--color-on-dark: #FBF8F2;
|
background: var(--color-primary);
|
||||||
--color-on-accent: #FDFBF7;
|
|
||||||
|
|
||||||
/* ── Lines, chips, placeholders ──────────────────────────────────────── */
|
|
||||||
--color-hairline: #F0EBE2;
|
|
||||||
--color-hover: #E7E0D2;
|
|
||||||
--color-selected: #F6EFE6;
|
|
||||||
--color-ring: #E2DACB;
|
|
||||||
--color-inactive: #DED7CA;
|
|
||||||
--color-inactive-strong: #C4BAA9;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-family: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
@@ -56,35 +42,4 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
font-family: "IBM Plex Sans", system-ui, sans-serif;
|
|
||||||
color: var(--color-text-body);
|
|
||||||
}
|
|
||||||
|
|
||||||
@utility font-heading {
|
|
||||||
font-family: "Newsreader", serif;
|
|
||||||
font-weight: 400;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Clip trim slider (react-range-slider-input). Its own stylesheet ships blue
|
|
||||||
defaults; the #id beats those class selectors regardless of import order. */
|
|
||||||
#range-slider {
|
|
||||||
height: 8px;
|
|
||||||
background: var(--color-fields);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#range-slider .range-slider__range {
|
|
||||||
background: var(--color-terracotta-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
#range-slider .range-slider__thumb {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
background: var(--color-terracotta);
|
|
||||||
box-shadow: 0 1px 2px rgba(90, 40, 20, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#range-slider .range-slider__thumb:focus-visible {
|
|
||||||
box-shadow: 0 0 0 4px color-mix(in oklab, var(--color-terracotta) 35%, transparent);
|
|
||||||
}
|
}
|
||||||
@@ -3,13 +3,27 @@ import Sidebar from '../components/Sidebar'
|
|||||||
import Topbar from '../components/Topbar'
|
import Topbar from '../components/Topbar'
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import { useAuth } from "../auth/useAuth";
|
import type {User} from "../utils/types";
|
||||||
|
import { getUser } from "../utils/api/users";
|
||||||
import { getStreamStatus } from "../utils/api/stream";
|
import { getStreamStatus } from "../utils/api/stream";
|
||||||
|
|
||||||
const MainLayout = () => {
|
const MainLayout = () => {
|
||||||
const [sidebarToggled, setSidebarToggled] = useState(false);
|
const [sidebarToggled, setSidebarToggled] = useState(false);
|
||||||
|
const [user, setUser] = useState<null | User>(null);
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
const { user } = useAuth();
|
|
||||||
|
const fetchUser = async () => {
|
||||||
|
try {
|
||||||
|
const userData = await getUser();
|
||||||
|
setUser(userData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch user:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUser();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
@@ -20,13 +34,16 @@ const MainLayout = () => {
|
|||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`transition-all duration-300 grid h-screen ${sidebarToggled ? "grid-cols-[0px_1fr]" : "grid-cols-[252px_1fr]"} grid-rows-[auto_1fr]`}>
|
<div className={`transition-all duration-300 grid h-screen ${sidebarToggled ? "grid-cols-[0px_1fr]" : "grid-cols-[240px_1fr]"} grid-rows-[auto_1fr]`}>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
|
user={user}
|
||||||
className={`row-span-2 transition-all duration-300 overflow-hidden whitespace-nowrap ${sidebarToggled ? "-translate-x-full" : "translate-x-0"}`}/>
|
className={`row-span-2 transition-all duration-300 overflow-hidden whitespace-nowrap ${sidebarToggled ? "-translate-x-full" : "translate-x-0"}`}/>
|
||||||
<Topbar
|
<Topbar
|
||||||
className={"transition-all duration-300"}
|
className={"transition-all duration-300"}
|
||||||
sidebarToggled={sidebarToggled}
|
sidebarToggled={sidebarToggled}
|
||||||
setSidebarToggled={setSidebarToggled}
|
setSidebarToggled={setSidebarToggled}
|
||||||
|
user={user}
|
||||||
|
fetchUser={fetchUser}
|
||||||
isStreaming={isStreaming}/>
|
isStreaming={isStreaming}/>
|
||||||
<div className="overflow-auto bg-background">
|
<div className="overflow-auto bg-background">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
@@ -10,9 +10,8 @@ import { getJob } from "../utils/api/jobs"
|
|||||||
import type { VideoMetadata } from "../utils/types.ts";
|
import type { VideoMetadata } from "../utils/types.ts";
|
||||||
import Box from "../components/Box.tsx";
|
import Box from "../components/Box.tsx";
|
||||||
import MetadataBox from "../components/video/MetadataBox.tsx";
|
import MetadataBox from "../components/video/MetadataBox.tsx";
|
||||||
import {config} from "../config.ts";
|
|
||||||
|
|
||||||
const API_URL = config.apiUrl;
|
const API_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
const ClipEdit = () => {
|
const ClipEdit = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -64,18 +63,12 @@ const ClipEdit = () => {
|
|||||||
const sendData = async () => {
|
const sendData = async () => {
|
||||||
if (!localFile || uploading) return;
|
if (!localFile || uploading) return;
|
||||||
|
|
||||||
const title = outputMetadata.title.trim();
|
|
||||||
if (!title) {
|
|
||||||
setError('A title is required before exporting.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
let jobId: string;
|
let jobId: string;
|
||||||
try {
|
try {
|
||||||
jobId = await compress(localFile, { ...outputMetadata, title });
|
jobId = await compress(localFile, outputMetadata);
|
||||||
setUploadedId(jobId);
|
setUploadedId(jobId);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setError(`Failed to start compression: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
setError(`Failed to start compression: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||||
@@ -86,7 +79,7 @@ const ClipEdit = () => {
|
|||||||
const interval = setInterval(async () => await pollProgress(jobId, interval), 500);
|
const interval = setInterval(async () => await pollProgress(jobId, interval), 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
const pollProgress = async (jobId: string, intervalId: ReturnType<typeof setInterval>) => {
|
const pollProgress = async (jobId: string, intervalId: number) => {
|
||||||
getJob(jobId)
|
getJob(jobId)
|
||||||
.then((job) => {
|
.then((job) => {
|
||||||
setProgress(job.progress);
|
setProgress(job.progress);
|
||||||
@@ -135,7 +128,7 @@ const ClipEdit = () => {
|
|||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={videoUrl}
|
src={videoUrl}
|
||||||
className={"w-full rounded-lg shadow-sm bg-text-primary"}
|
className={"w-full rounded-lg shadow-sm bg-black"}
|
||||||
onLoadedMetadata={handleVideoMetadataLoaded}
|
onLoadedMetadata={handleVideoMetadataLoaded}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -149,7 +142,7 @@ const ClipEdit = () => {
|
|||||||
<Playbar
|
<Playbar
|
||||||
video={videoRef.current}
|
video={videoRef.current}
|
||||||
videoMetadata={metadata}
|
videoMetadata={metadata}
|
||||||
className={"w-full accent-terracotta"}
|
className={"w-full accent-primary"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PlaybackSlider
|
<PlaybackSlider
|
||||||
@@ -157,7 +150,7 @@ const ClipEdit = () => {
|
|||||||
videoMetadata={metadata}
|
videoMetadata={metadata}
|
||||||
sliderValue={playbackValue}
|
sliderValue={playbackValue}
|
||||||
setSliderValue={setPlaybackValue}
|
setSliderValue={setPlaybackValue}
|
||||||
className={"w-full accent-terracotta"}
|
className={"w-full accent-primary"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ClipRangeSlider
|
<ClipRangeSlider
|
||||||
@@ -165,11 +158,11 @@ const ClipEdit = () => {
|
|||||||
videoMetadata={metadata}
|
videoMetadata={metadata}
|
||||||
setSliderValue={setPlaybackValue}
|
setSliderValue={setPlaybackValue}
|
||||||
setMetadata={setOutputMetadata}
|
setMetadata={setOutputMetadata}
|
||||||
className={"w-full mt-2"}
|
className={"w-full mt-2 bg-primary"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className={"text-error text-center mt-2"}>
|
<div className={"text-red-600 text-center mt-2"}>
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import {useState} from "react";
|
import {useState} from "react";
|
||||||
import {useNavigate} from "react-router-dom";
|
import {useNavigate} from "react-router-dom";
|
||||||
|
import BlueButton from "../components/buttons/BlueButton.tsx";
|
||||||
import Box from "../components/Box.tsx";
|
import Box from "../components/Box.tsx";
|
||||||
|
|
||||||
const ClipUpload = () => {
|
const ClipUpload = () => {
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [error, setError] = useState<null | string>(null);
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [error, setError] = useState<null | string>(null);
|
||||||
|
|
||||||
const press = () => {
|
const press = () => {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
@@ -16,17 +17,10 @@ const ClipUpload = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center p-8">
|
<div className="flex items-start justify-center p-8 min-h-full">
|
||||||
<Box className="flex flex-col gap-4 p-6 w-full max-w-md">
|
<Box className="flex flex-col gap-4 p-6 w-full max-w-md">
|
||||||
<div
|
<h1 className="text-lg font-semibold text-gray-900">Upload Clip</h1>
|
||||||
className="cursor-pointer rounded-lg border-2
|
|
||||||
border-dashed border-hairline bg-dropzone
|
|
||||||
text-center text-sm text-text-secondary
|
|
||||||
hover:bg-hover transition-colors duration-150
|
|
||||||
min-h-50 flex flex-col items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<label className="cursor-pointer flex flex-col items-center justify-center gap-2 w-full h-full">
|
|
||||||
Choose file
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -34,22 +28,12 @@ const ClipUpload = () => {
|
|||||||
setFile(selected);
|
setFile(selected);
|
||||||
setError(null);
|
setError(null);
|
||||||
}}
|
}}
|
||||||
className="hidden"
|
className="h-36 cursor-pointer rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-4 text-center text-sm text-gray-500 hover:bg-gray-100 hover:border-primary transition-colors duration-150"
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
{file && (
|
|
||||||
<p className="mt-2 text-xs text-text-secondary">{file.name}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<BlueButton onClick={press}>Upload</BlueButton>
|
||||||
onClick={press}
|
|
||||||
className={"hover:bg-hover p-1 rounded-md"}
|
|
||||||
>
|
|
||||||
Upload
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{error && <p className="text-sm font-body text-center">{error}</p>}
|
{error && <p className="text-sm text-red-500 text-center">{error}</p>}
|
||||||
</Box>
|
</Box>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { useNavigate, useParams } from "react-router-dom";
|
|||||||
import { ArrowLeft, Check } from "lucide-react";
|
import { ArrowLeft, Check } from "lucide-react";
|
||||||
import { getClipById, patchClip } from "../utils/api/clips.ts";
|
import { getClipById, patchClip } from "../utils/api/clips.ts";
|
||||||
import Box from "../components/Box.tsx";
|
import Box from "../components/Box.tsx";
|
||||||
import PrimaryButton from "../components/buttons/PrimaryButton.tsx";
|
import BlueButton from "../components/buttons/BlueButton.tsx";
|
||||||
|
|
||||||
const inputClass = "border border-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors";
|
const inputClass = "border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors";
|
||||||
|
|
||||||
const EditClip = () => {
|
const EditClip = () => {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -50,20 +50,20 @@ const EditClip = () => {
|
|||||||
<div className="px-8 py-10 max-w-2xl mx-auto">
|
<div className="px-8 py-10 max-w-2xl mx-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
className="flex items-center gap-1.5 text-sm text-text-secondary hover:text-text-primary mb-6 transition-colors duration-150"
|
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} />
|
<ArrowLeft size={16} />
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<h1 className="text-4xl font-heading text-text-primary mb-6">Edit Clip</h1>
|
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Edit Clip</h1>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-muted text-sm">Loading...</div>
|
<div className="text-gray-400 text-sm">Loading...</div>
|
||||||
) : (
|
) : (
|
||||||
<Box className="p-6 flex flex-col gap-4">
|
<Box className="p-6 flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-sm font-medium text-text-secondary">Title</label>
|
<label className="text-sm font-medium text-gray-700">Title</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={title}
|
value={title}
|
||||||
@@ -74,7 +74,7 @@ const EditClip = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-sm font-medium text-text-secondary">Description</label>
|
<label className="text-sm font-medium text-gray-700">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
@@ -84,17 +84,17 @@ const EditClip = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-error">{error}</p>}
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-1">
|
<div className="flex items-center gap-3 pt-1">
|
||||||
<PrimaryButton onClick={handleSave} disabled={saving} className="w-28">
|
<BlueButton onClick={handleSave} disabled={saving} className="w-28">
|
||||||
{saving ? "Saving…" : saved
|
{saving ? "Saving…" : saved
|
||||||
? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span>
|
? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span>
|
||||||
: "Save"}
|
: "Save"}
|
||||||
</PrimaryButton>
|
</BlueButton>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
className="text-sm text-text-secondary hover:text-text-primary transition-colors duration-150"
|
className="text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { useNavigate, useParams } from "react-router-dom";
|
|||||||
import { ArrowLeft, Check } from "lucide-react";
|
import { ArrowLeft, Check } from "lucide-react";
|
||||||
import { getVodById, patchVod } from "../utils/api/vods.ts";
|
import { getVodById, patchVod } from "../utils/api/vods.ts";
|
||||||
import Box from "../components/Box.tsx";
|
import Box from "../components/Box.tsx";
|
||||||
import PrimaryButton from "../components/buttons/PrimaryButton.tsx";
|
import BlueButton from "../components/buttons/BlueButton.tsx";
|
||||||
|
|
||||||
const inputClass = "border border-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors";
|
const inputClass = "border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors";
|
||||||
|
|
||||||
const EditVod = () => {
|
const EditVod = () => {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -50,20 +50,20 @@ const EditVod = () => {
|
|||||||
<div className="px-8 py-10 max-w-2xl mx-auto">
|
<div className="px-8 py-10 max-w-2xl mx-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
className="flex items-center gap-1.5 text-sm text-text-secondary hover:text-text-primary mb-6 transition-colors duration-150"
|
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} />
|
<ArrowLeft size={16} />
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<h1 className="text-4xl font-heading text-text-primary mb-6">Edit VoD</h1>
|
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Edit VoD</h1>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-muted text-sm">Loading...</div>
|
<div className="text-gray-400 text-sm">Loading...</div>
|
||||||
) : (
|
) : (
|
||||||
<Box className="p-6 flex flex-col gap-4">
|
<Box className="p-6 flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-sm font-medium text-text-secondary">Title</label>
|
<label className="text-sm font-medium text-gray-700">Title</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={title}
|
value={title}
|
||||||
@@ -74,7 +74,7 @@ const EditVod = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-sm font-medium text-text-secondary">Description</label>
|
<label className="text-sm font-medium text-gray-700">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
@@ -84,17 +84,17 @@ const EditVod = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-error">{error}</p>}
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-1">
|
<div className="flex items-center gap-3 pt-1">
|
||||||
<PrimaryButton onClick={handleSave} disabled={saving} className="w-28">
|
<BlueButton onClick={handleSave} disabled={saving} className="w-28">
|
||||||
{saving ? "Saving…" : saved
|
{saving ? "Saving…" : saved
|
||||||
? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span>
|
? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span>
|
||||||
: "Save"}
|
: "Save"}
|
||||||
</PrimaryButton>
|
</BlueButton>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
className="text-sm text-text-secondary hover:text-text-primary transition-colors duration-150"
|
className="text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ const Home = () => {
|
|||||||
alt="VoD System Logo"
|
alt="VoD System Logo"
|
||||||
className="h-24 mb-6 object-contain"
|
className="h-24 mb-6 object-contain"
|
||||||
/>
|
/>
|
||||||
<h1 className="text-4xl font-heading text-text-primary">
|
<h1 className="text-4xl font-semibold text-gray-900 mb-3 leading-tight">
|
||||||
A home for your clips.
|
A home for your clips.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-text-secondary">
|
<p className="text-base text-gray-500 leading-relaxed">
|
||||||
Upload, trim, and export video clips. Stream live if you need to.
|
Upload, trim, and export video clips. Stream live if you need to.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,21 +56,21 @@ const Home = () => {
|
|||||||
{features.map(({ icon: Icon, title, description }) => (
|
{features.map(({ icon: Icon, title, description }) => (
|
||||||
<div
|
<div
|
||||||
key={title}
|
key={title}
|
||||||
className="bg-card border border-hairline rounded-xl p-5 flex flex-col gap-3 shadow-sm"
|
className="bg-white border border-gray-200 rounded-xl p-5 flex flex-col gap-3 shadow-sm"
|
||||||
>
|
>
|
||||||
<div className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0">
|
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||||
<Icon size={18} className="text-terracotta" />
|
<Icon size={18} className="text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-body text-text-strong font-bold text-sm mb-1">{title}</p>
|
<p className="font-semibold text-gray-900 text-sm mb-1">{title}</p>
|
||||||
<p className="text-sm text-text-secondary leading-relaxed">{description}</p>
|
<p className="text-sm text-gray-500 leading-relaxed">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Format note */}
|
{/* Format note */}
|
||||||
<p className="text-xs text-muted">
|
<p className="text-xs text-gray-400">
|
||||||
Supports MP4, MOV, WebM, and OGG.
|
Supports MP4, MOV, WebM, and OGG.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const MyClips = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<h1 className="text-4xl font-heading text-text-primary mb-6">My Clips</h1>
|
<h1 className="text-2xl font-semibold text-gray-900 mb-6">My Clips</h1>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
{clips.map((clip) => (
|
{clips.map((clip) => (
|
||||||
@@ -43,13 +43,13 @@ const MyClips = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{clips.length === 0 && !error && (
|
{clips.length === 0 && !error && (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-muted">
|
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||||
<p className="text-base">No clips yet.</p>
|
<p className="text-base">No clips yet.</p>
|
||||||
<p className="text-sm mt-1">Upload your first clip to get started.</p>
|
<p className="text-sm mt-1">Upload your first clip to get started.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <p className="text-sm text-error mt-4">{error.toString()}</p>}
|
{error && <p className="text-sm text-red-500 mt-4">{error.toString()}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const MyVods = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<h1 className="text-4xl font-heading text-text-primary mb-6">My VoDs</h1>
|
<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">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
{vods.map((vod) => (
|
{vods.map((vod) => (
|
||||||
@@ -46,13 +46,13 @@ const MyVods = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{vods.length === 0 && !error && (
|
{vods.length === 0 && !error && (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-muted">
|
<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-base">No VoDs yet.</p>
|
||||||
<p className="text-sm mt-1">Your stream recordings will appear here.</p>
|
<p className="text-sm mt-1">Your stream recordings will appear here.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <p className="text-sm text-error mt-4">{error}</p>}
|
{error && <p className="text-sm text-red-500 mt-4">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import { Lock } from 'lucide-react';
|
|
||||||
import {useNavigate} from "react-router-dom";
|
|
||||||
|
|
||||||
const NotFound = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const goHomePage = () => {
|
|
||||||
navigate("/")
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full min-h-screen flex flex-col items-center justify-center gap-3 text-center px-6">
|
|
||||||
<Lock className="w-8 h-8 text-faint-mono/60 mb-1" strokeWidth={1.5} />
|
|
||||||
<h2 className="font-heading text-2xl md:text-3xl text-faint-mono">
|
|
||||||
This page does not exist.
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-faint-mono/70 max-w-xs">
|
|
||||||
<a onClick={goHomePage} className={"font-bold cursor-pointer"}>Click here</a> to get back to the home page.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default NotFound;
|
|
||||||
@@ -1,14 +1,19 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Eye, EyeOff, Copy, Check } from "lucide-react";
|
import { Eye, EyeOff, Copy, Check } from "lucide-react";
|
||||||
import Box from "../components/Box.tsx";
|
import Box from "../components/Box.tsx";
|
||||||
import { useAuth } from "../auth/useAuth.ts";
|
import { getUser } from "../utils/api/users.ts";
|
||||||
|
import type { User } from "../utils/types.ts";
|
||||||
import { formatLocalDate } from "../utils/utils.ts";
|
import { formatLocalDate } from "../utils/utils.ts";
|
||||||
|
|
||||||
const Profile = () => {
|
const Profile = () => {
|
||||||
const { user } = useAuth();
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [keyVisible, setKeyVisible] = useState(false);
|
const [keyVisible, setKeyVisible] = useState(false);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getUser().then(setUser).catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
if (!user?.streamKey) return;
|
if (!user?.streamKey) return;
|
||||||
navigator.clipboard.writeText(user.streamKey);
|
navigator.clipboard.writeText(user.streamKey);
|
||||||
@@ -18,7 +23,7 @@ const Profile = () => {
|
|||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center h-full text-muted text-base">
|
<div className="flex justify-center items-center h-full text-gray-400 text-base">
|
||||||
Please log in to view your profile.
|
Please log in to view your profile.
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -26,7 +31,7 @@ const Profile = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-8 py-10 max-w-2xl mx-auto">
|
<div className="px-8 py-10 max-w-2xl mx-auto">
|
||||||
<h1 className="text-4xl font-heading text-text-primary mb-6">Profile</h1>
|
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Profile</h1>
|
||||||
|
|
||||||
<Box className="p-6 mb-4 flex items-center gap-5">
|
<Box className="p-6 mb-4 flex items-center gap-5">
|
||||||
<img
|
<img
|
||||||
@@ -36,31 +41,31 @@ const Profile = () => {
|
|||||||
className="w-20 h-20 rounded-full shadow-sm"
|
className="w-20 h-20 rounded-full shadow-sm"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-2xl font-heading text-text-primary">{user.name}</p>
|
<p className="text-lg font-semibold text-gray-900">{user.name}</p>
|
||||||
<p className="text-sm text-text-secondary">@{user.username}</p>
|
<p className="text-sm text-gray-500">@{user.username}</p>
|
||||||
<p className="text-sm text-text-secondary">{user.email}</p>
|
<p className="text-sm text-gray-500">{user.email}</p>
|
||||||
<p className="text-xs text-muted mt-1">
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
Member since {formatLocalDate(user.createdAt)}
|
Member since {formatLocalDate(user.createdAt)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box className="p-6">
|
<Box className="p-6">
|
||||||
<h2 className="text-xl font-heading text-text-primary mb-1">Stream Key</h2>
|
<h2 className="text-sm font-semibold text-gray-800 mb-1">Stream Key</h2>
|
||||||
<p className="text-sm text-text-secondary mb-4">
|
<p className="text-sm text-gray-500 mb-4">
|
||||||
Use this key in OBS or any RTMP-compatible software to start streaming.
|
Use this key in OBS or any RTMP-compatible software to start streaming.
|
||||||
Keep it private — anyone with this key can stream to your account.
|
Keep it private — anyone with this key can stream to your account.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex-1 bg-fields border border-hairline rounded-md px-4 py-2 font-data text-sm text-text-strong overflow-hidden">
|
<div className="flex-1 bg-gray-50 border border-gray-200 rounded-md px-4 py-2 font-mono text-sm text-gray-700 overflow-hidden">
|
||||||
{keyVisible ? user.streamKey : "•".repeat(user.streamKey.length)}
|
{keyVisible ? user.streamKey : "•".repeat(user.streamKey.length)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setKeyVisible(v => !v)}
|
onClick={() => setKeyVisible(v => !v)}
|
||||||
title={keyVisible ? "Hide key" : "Show key"}
|
title={keyVisible ? "Hide key" : "Show key"}
|
||||||
className="p-2 rounded-md bg-fields hover:bg-hover transition-colors duration-150 text-text-secondary"
|
className="p-2 rounded-md bg-gray-100 hover:bg-gray-200 transition-colors duration-150 text-gray-600"
|
||||||
>
|
>
|
||||||
{keyVisible ? <EyeOff size={16} /> : <Eye size={16} />}
|
{keyVisible ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
</button>
|
</button>
|
||||||
@@ -68,9 +73,9 @@ const Profile = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
title="Copy key"
|
title="Copy key"
|
||||||
className="p-2 rounded-md bg-fields hover:bg-hover transition-colors duration-150 text-text-secondary"
|
className="p-2 rounded-md bg-gray-100 hover:bg-gray-200 transition-colors duration-150 text-gray-600"
|
||||||
>
|
>
|
||||||
{copied ? <Check size={16} className="text-olive" /> : <Copy size={16} />}
|
{copied ? <Check size={16} className="text-green-600" /> : <Copy size={16} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -2,17 +2,17 @@ import { useEffect, useState } from "react";
|
|||||||
import { Radio, Check, Bookmark, Scissors, Film, Activity, Loader2, X } from "lucide-react";
|
import { Radio, Check, Bookmark, Scissors, Film, Activity, Loader2, X } from "lucide-react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import Box from "../components/Box.tsx";
|
import Box from "../components/Box.tsx";
|
||||||
import PrimaryButton from "../components/buttons/PrimaryButton.tsx";
|
import BlueButton from "../components/buttons/BlueButton.tsx";
|
||||||
import { useAuth } from "../auth/useAuth.ts";
|
import { getUser } from "../utils/api/users.ts";
|
||||||
import { getCurrentStream, getStreamHistory } from "../utils/api/stream.ts";
|
import { getCurrentStream, getStreamHistory } from "../utils/api/stream.ts";
|
||||||
import { getMarkers, createMarker, deleteMarker } from "../utils/api/markers.ts";
|
import { getMarkers, createMarker, deleteMarker } from "../utils/api/markers.ts";
|
||||||
import { saveSectionByMarkers, clipSection } from "../utils/api/media.ts";
|
import { saveSectionByMarkers, clipSection } from "../utils/api/media.ts";
|
||||||
import { getJob } from "../utils/api/jobs.ts";
|
import { getJob } from "../utils/api/jobs.ts";
|
||||||
import type { StreamStatus, StreamHistoryItem, Marker, JobResponse } from "../utils/types.ts";
|
import type { User, StreamStatus, StreamHistoryItem, Marker, JobResponse } from "../utils/types.ts";
|
||||||
import { formatTime, formatLocalDate, stringToDate } from "../utils/utils.ts";
|
import { formatTime, formatLocalDate, stringToDate } from "../utils/utils.ts";
|
||||||
|
|
||||||
const inputClass = "border border-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors";
|
const inputClass = "border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors";
|
||||||
const labelClass = "font-data text-[11px] text-muted uppercase tracking-[0.12em]";
|
const labelClass = "text-xs font-medium text-gray-500 uppercase tracking-wide";
|
||||||
|
|
||||||
type TrackedJob = {
|
type TrackedJob = {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
@@ -24,10 +24,10 @@ type TrackedJob = {
|
|||||||
|
|
||||||
const CardHeader = ({ icon: Icon, title, accent = "primary" }: { icon: React.ElementType; title: string; accent?: "primary" | "accent" }) => (
|
const CardHeader = ({ icon: Icon, title, accent = "primary" }: { icon: React.ElementType; title: string; accent?: "primary" | "accent" }) => (
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className={clsx("w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0", accent === "primary" ? "bg-terracotta/10" : "bg-olive/10")}>
|
<div className={clsx("w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0", accent === "primary" ? "bg-primary/10" : "bg-accent/10")}>
|
||||||
<Icon size={16} className={accent === "primary" ? "text-terracotta" : "text-olive"} />
|
<Icon size={16} className={accent === "primary" ? "text-primary" : "text-accent"} />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-heading text-text-primary">{title}</h2>
|
<h2 className="text-sm font-semibold text-gray-800">{title}</h2>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -35,32 +35,32 @@ const JobStatus = ({ job }: { job: TrackedJob }) => {
|
|||||||
if (job.state === 'FAILED') {
|
if (job.state === 'FAILED') {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-2 text-sm">
|
<div className="flex items-start gap-2 text-sm">
|
||||||
<X size={15} className="text-error mt-0.5 flex-shrink-0" />
|
<X size={15} className="text-red-500 mt-0.5 flex-shrink-0" />
|
||||||
<p className="text-error">{job.label} failed: {job.errorOutput ?? "Unknown error"}</p>
|
<p className="text-red-500">{job.label} failed: {job.errorOutput ?? "Unknown error"}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (job.state === 'SUCCEEDED') {
|
if (job.state === 'SUCCEEDED') {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<Check size={15} className="text-olive flex-shrink-0" />
|
<Check size={15} className="text-green-600 flex-shrink-0" />
|
||||||
<p className="text-text-body">{job.label} saved</p>
|
<p className="text-gray-700">{job.label} saved</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Loader2 size={15} className="text-terracotta animate-spin flex-shrink-0" />
|
<Loader2 size={15} className="text-primary animate-spin flex-shrink-0" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm text-text-secondary mb-1">{job.label}…</p>
|
<p className="text-sm text-gray-600 mb-1">{job.label}…</p>
|
||||||
<progress value={job.progress} className="w-full h-1.5 rounded bg-fields" />
|
<progress value={job.progress} className="w-full h-1.5 rounded bg-gray-200" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const StreamControl = () => {
|
const StreamControl = () => {
|
||||||
const { user } = useAuth();
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [streamStatus, setStreamStatus] = useState<StreamStatus | null>(null);
|
const [streamStatus, setStreamStatus] = useState<StreamStatus | null>(null);
|
||||||
const [streamDetails, setStreamDetails] = useState<StreamHistoryItem | null>(null);
|
const [streamDetails, setStreamDetails] = useState<StreamHistoryItem | null>(null);
|
||||||
const [now, setNow] = useState(Date.now());
|
const [now, setNow] = useState(Date.now());
|
||||||
@@ -85,6 +85,10 @@ const StreamControl = () => {
|
|||||||
|
|
||||||
const isStreaming = streamStatus?.isStreaming ?? false;
|
const isStreaming = streamStatus?.isStreaming ?? false;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getUser().then(setUser).catch(() => setUser(null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
const poll = () => getCurrentStream().then(setStreamStatus).catch(() => {});
|
const poll = () => getCurrentStream().then(setStreamStatus).catch(() => {});
|
||||||
@@ -222,7 +226,7 @@ const StreamControl = () => {
|
|||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center h-full text-muted text-base">
|
<div className="flex justify-center items-center h-full text-gray-400 text-base">
|
||||||
Please log in to control your stream.
|
Please log in to control your stream.
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -237,35 +241,35 @@ const StreamControl = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-8 py-10 max-w-5xl mx-auto flex flex-col gap-5">
|
<div className="px-8 py-10 max-w-5xl mx-auto flex flex-col gap-5">
|
||||||
<h1 className="text-4xl font-heading text-text-primary">Stream Control</h1>
|
<h1 className="text-2xl font-semibold text-gray-900">Stream Control</h1>
|
||||||
|
|
||||||
{/* Hero status bar */}
|
{/* Hero status bar */}
|
||||||
<Box className={clsx(
|
<Box className={clsx(
|
||||||
"p-6 flex items-center justify-between gap-6 border-l-4",
|
"p-6 flex items-center justify-between gap-6 border-l-4",
|
||||||
isStreaming ? "border-l-live" : "border-l-hairline"
|
isStreaming ? "border-l-red-500" : "border-l-gray-200"
|
||||||
)}>
|
)}>
|
||||||
<div className="flex items-center gap-3.5">
|
<div className="flex items-center gap-3.5">
|
||||||
<span className="relative flex h-3.5 w-3.5">
|
<span className="relative flex h-3.5 w-3.5">
|
||||||
{isStreaming && <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-live opacity-75" />}
|
{isStreaming && <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-500 opacity-75" />}
|
||||||
<span className={clsx("relative inline-flex rounded-full h-3.5 w-3.5", isStreaming ? "bg-live" : "bg-inactive-strong")} />
|
<span className={clsx("relative inline-flex rounded-full h-3.5 w-3.5", isStreaming ? "bg-red-500" : "bg-gray-300")} />
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-2xl font-heading text-text-primary leading-tight">{isStreaming ? "Live" : "Offline"}</p>
|
<p className="font-semibold text-gray-900 text-lg leading-tight">{isStreaming ? "Live" : "Offline"}</p>
|
||||||
{isStreaming && streamDetails?.startDate ? (
|
{isStreaming && streamDetails?.startDate ? (
|
||||||
<p className="text-sm text-text-secondary">Started {formatLocalDate(streamDetails.startDate)}</p>
|
<p className="text-sm text-gray-500">Started {formatLocalDate(streamDetails.startDate)}</p>
|
||||||
) : !isStreaming ? (
|
) : !isStreaming ? (
|
||||||
<p className="text-sm text-text-secondary">Point OBS at your stream key (see Profile) to go live.</p>
|
<p className="text-sm text-gray-500">Point OBS at your stream key (see Profile) to go live.</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isStreaming && elapsed ? (
|
{isStreaming && elapsed ? (
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-2xl font-data text-text-primary tabular-nums">{elapsed}</p>
|
<p className="text-2xl font-mono font-semibold text-gray-900 tabular-nums">{elapsed}</p>
|
||||||
<p className={labelClass}>elapsed</p>
|
<p className="text-xs text-gray-400 uppercase tracking-wide">elapsed</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Radio size={28} className="text-inactive" />
|
<Radio size={28} className="text-gray-200" />
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -274,7 +278,7 @@ const StreamControl = () => {
|
|||||||
{/* Quick mark toolbar */}
|
{/* Quick mark toolbar */}
|
||||||
<Box className="p-4 flex flex-col gap-3">
|
<Box className="p-4 flex flex-col gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Bookmark size={18} className="text-olive flex-shrink-0" />
|
<Bookmark size={18} className="text-accent flex-shrink-0" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={markerMessage}
|
value={markerMessage}
|
||||||
@@ -283,11 +287,11 @@ const StreamControl = () => {
|
|||||||
placeholder="What just happened?"
|
placeholder="What just happened?"
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
<PrimaryButton onClick={handleAddMarker} disabled={markerBusy || !markerMessage.trim()} className="whitespace-nowrap">
|
<BlueButton onClick={handleAddMarker} disabled={markerBusy || !markerMessage.trim()} className="whitespace-nowrap">
|
||||||
Add Marker
|
Add Marker
|
||||||
</PrimaryButton>
|
</BlueButton>
|
||||||
</div>
|
</div>
|
||||||
{markerError && <p className="text-sm text-error">{markerError}</p>}
|
{markerError && <p className="text-sm text-red-500">{markerError}</p>}
|
||||||
|
|
||||||
{markers.length > 0 && (
|
{markers.length > 0 && (
|
||||||
<div className="flex gap-2 overflow-x-auto pb-0.5 -mx-1 px-1 pt-1.5">
|
<div className="flex gap-2 overflow-x-auto pb-0.5 -mx-1 px-1 pt-1.5">
|
||||||
@@ -300,22 +304,22 @@ const StreamControl = () => {
|
|||||||
onClick={() => handleChipClick(m.id)}
|
onClick={() => handleChipClick(m.id)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex flex-col items-start gap-0.5 rounded-lg border px-3 py-1.5 text-left transition-colors duration-150",
|
"flex flex-col items-start gap-0.5 rounded-lg border px-3 py-1.5 text-left transition-colors duration-150",
|
||||||
isStart && "border-terracotta bg-selected ring-1 ring-terracotta",
|
isStart && "border-primary bg-primary/5 ring-1 ring-primary",
|
||||||
isEnd && "border-olive bg-selected ring-1 ring-olive",
|
isEnd && "border-accent bg-accent/5 ring-1 ring-accent",
|
||||||
!isStart && !isEnd && "border-hairline hover:border-ring hover:bg-hover"
|
!isStart && !isEnd && "border-gray-200 hover:border-gray-300 hover:bg-gray-50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="text-xs font-medium text-text-strong max-w-40 truncate">{m.message}</span>
|
<span className="text-xs font-medium text-gray-800 max-w-40 truncate">{m.message}</span>
|
||||||
<span className="font-data text-[11px] text-muted">
|
<span className="text-[11px] text-gray-400">
|
||||||
{formatLocalDate(m.timestamp, { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
|
{formatLocalDate(m.timestamp, { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
|
||||||
{isStart && <span className="text-terracotta font-medium"> · start</span>}
|
{isStart && <span className="text-primary font-medium"> · start</span>}
|
||||||
{isEnd && <span className="text-olive font-medium"> · end</span>}
|
{isEnd && <span className="text-accent font-medium"> · end</span>}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); handleDeleteMarker(m.id); }}
|
onClick={(e) => { e.stopPropagation(); handleDeleteMarker(m.id); }}
|
||||||
title="Delete marker"
|
title="Delete marker"
|
||||||
className="hidden group-hover:flex items-center justify-center absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-muted hover:bg-error text-on-accent transition-colors duration-150"
|
className="hidden group-hover:flex items-center justify-center absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-gray-400 hover:bg-red-500 text-white transition-colors duration-150"
|
||||||
>
|
>
|
||||||
<X size={10} />
|
<X size={10} />
|
||||||
</button>
|
</button>
|
||||||
@@ -342,44 +346,44 @@ const StreamControl = () => {
|
|||||||
</div>
|
</div>
|
||||||
<input type="text" placeholder="Title" value={clipTitle} onChange={(e) => setClipTitle(e.target.value)} className={inputClass} />
|
<input type="text" placeholder="Title" value={clipTitle} onChange={(e) => setClipTitle(e.target.value)} className={inputClass} />
|
||||||
<textarea placeholder="Description" value={clipDescription} onChange={(e) => setClipDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} />
|
<textarea placeholder="Description" value={clipDescription} onChange={(e) => setClipDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} />
|
||||||
{clipError && <p className="text-sm text-error">{clipError}</p>}
|
{clipError && <p className="text-sm text-red-500">{clipError}</p>}
|
||||||
<PrimaryButton onClick={handleClip} className="self-start mt-auto">Clip</PrimaryButton>
|
<BlueButton onClick={handleClip} className="self-start mt-auto">Clip</BlueButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box className="p-6 flex flex-col gap-3">
|
<Box className="p-6 flex flex-col gap-3">
|
||||||
<CardHeader icon={Film} title="Save between two markers" accent="accent" />
|
<CardHeader icon={Film} title="Save between two markers" accent="accent" />
|
||||||
{markers.length < 2 ? (
|
{markers.length < 2 ? (
|
||||||
<p className="text-sm text-muted">Add at least two markers above to save a section between them.</p>
|
<p className="text-sm text-gray-400">Add at least two markers above to save a section between them.</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className={clsx(
|
<span className={clsx(
|
||||||
"text-xs font-medium px-2.5 py-1 rounded-full border",
|
"text-xs font-medium px-2.5 py-1 rounded-full border",
|
||||||
startMarker ? "border-terracotta text-terracotta bg-selected" : "border-dashed border-inactive text-muted"
|
startMarker ? "border-primary text-primary bg-primary/5" : "border-dashed border-gray-300 text-gray-400"
|
||||||
)}>
|
)}>
|
||||||
{startMarker ? startMarker.message : "Tap a marker to set start"}
|
{startMarker ? startMarker.message : "Tap a marker to set start"}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-inactive-strong">→</span>
|
<span className="text-gray-300">→</span>
|
||||||
<span className={clsx(
|
<span className={clsx(
|
||||||
"text-xs font-medium px-2.5 py-1 rounded-full border",
|
"text-xs font-medium px-2.5 py-1 rounded-full border",
|
||||||
endMarker ? "border-olive text-olive bg-selected" : "border-dashed border-inactive text-muted"
|
endMarker ? "border-accent text-accent bg-accent/5" : "border-dashed border-gray-300 text-gray-400"
|
||||||
)}>
|
)}>
|
||||||
{endMarker ? endMarker.message : "Tap a marker to set end"}
|
{endMarker ? endMarker.message : "Tap a marker to set end"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" placeholder="Title" value={markerSaveTitle} onChange={(e) => setMarkerSaveTitle(e.target.value)} className={inputClass} />
|
<input type="text" placeholder="Title" value={markerSaveTitle} onChange={(e) => setMarkerSaveTitle(e.target.value)} className={inputClass} />
|
||||||
<textarea placeholder="Description" value={markerSaveDescription} onChange={(e) => setMarkerSaveDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} />
|
<textarea placeholder="Description" value={markerSaveDescription} onChange={(e) => setMarkerSaveDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} />
|
||||||
{markerSaveError && <p className="text-sm text-error">{markerSaveError}</p>}
|
{markerSaveError && <p className="text-sm text-red-500">{markerSaveError}</p>}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<PrimaryButton onClick={handleSaveByMarkers} disabled={markers.length < 2} className="self-start mt-auto">Save</PrimaryButton>
|
<BlueButton onClick={handleSaveByMarkers} disabled={markers.length < 2} className="self-start mt-auto">Save</BlueButton>
|
||||||
</Box>
|
</Box>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Box className="p-5 flex flex-col gap-3">
|
<Box className="p-5 flex flex-col gap-3">
|
||||||
<CardHeader icon={Activity} title="Recent actions" />
|
<CardHeader icon={Activity} title="Recent actions" />
|
||||||
{jobs.length === 0 ? (
|
{jobs.length === 0 ? (
|
||||||
<p className="text-sm text-muted">Clip and save actions will show up here.</p>
|
<p className="text-sm text-gray-400">Clip and save actions will show up here.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{jobs.map((job) => <JobStatus key={job.uuid} job={job} />)}
|
{jobs.map((job) => <JobStatus key={job.uuid} job={job} />)}
|
||||||
|
|||||||
@@ -1,27 +1,13 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { ArrowLeft, Lock, Maximize, Minimize, Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react";
|
import { ArrowLeft, Lock, Maximize, Minimize, Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react";
|
||||||
|
import type { Clip } from "../utils/types";
|
||||||
import { AuthError } from "../utils/api/client.ts";
|
import { AuthError } from "../utils/api/client.ts";
|
||||||
|
import { getClipById, getVideoBlob } from "../utils/api/clips.ts";
|
||||||
import Box from "../components/Box.tsx";
|
import Box from "../components/Box.tsx";
|
||||||
import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts";
|
import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts";
|
||||||
|
|
||||||
// Both VoDs and clips share the same on-screen shape.
|
const VideoPlayer = () => {
|
||||||
type MediaItem = {
|
|
||||||
title: string,
|
|
||||||
createdAt: string,
|
|
||||||
};
|
|
||||||
|
|
||||||
type MediaPlayerProps = {
|
|
||||||
/** Human-readable name for the media, e.g. "VoD" or "clip". Used in messages. */
|
|
||||||
noun: string,
|
|
||||||
/** Builds the raw media stream URL for the given id. Loaded directly by the
|
|
||||||
* <video> element so the browser can issue HTTP Range requests. */
|
|
||||||
getMediaUrl: (id: string) => string,
|
|
||||||
/** Fetches the media metadata for the given id. */
|
|
||||||
fetchDetails: (id: string) => Promise<MediaItem | null>,
|
|
||||||
};
|
|
||||||
|
|
||||||
const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -31,11 +17,10 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
const mediaUrl = id ? getMediaUrl(id) : undefined;
|
const [videoUrl, setVideoUrl] = useState<string | undefined>(undefined);
|
||||||
const [mediaLoaded, setMediaLoaded] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [notAuthenticated, setNotAuthenticated] = useState(false);
|
const [notAuthenticated, setNotAuthenticated] = useState(false);
|
||||||
const [item, setItem] = useState<MediaItem | null>(null);
|
const [clip, setClip] = useState<Clip | null>(null);
|
||||||
const [timeAgo, setTimeAgo] = useState("");
|
const [timeAgo, setTimeAgo] = useState("");
|
||||||
|
|
||||||
// Playback
|
// Playback
|
||||||
@@ -55,28 +40,28 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
// ── Data loading ────────────────────────────────────────────────────────
|
// ── Data loading ────────────────────────────────────────────────────────
|
||||||
// The video stream itself is loaded by the <video> element from `mediaUrl`,
|
|
||||||
// which lets the browser fetch it incrementally via Range requests. We only
|
|
||||||
// need to fetch the metadata here; it shares the media endpoint's auth, so a
|
|
||||||
// 401/403 here also stands in for the stream being inaccessible.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) { setError(`${noun} ID is required.`); return; }
|
if (!id) { setError("Clip ID is required."); return; }
|
||||||
|
|
||||||
fetchDetails(id)
|
getVideoBlob(id)
|
||||||
.then(setItem)
|
.then((blob) => setVideoUrl(URL.createObjectURL(blob)))
|
||||||
|
.catch((e) => { if (e instanceof AuthError) setNotAuthenticated(true); });
|
||||||
|
|
||||||
|
getClipById(id)
|
||||||
|
.then(setClip)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (e instanceof AuthError) setNotAuthenticated(true);
|
if (e instanceof AuthError) setNotAuthenticated(true);
|
||||||
else setError(`Failed to load ${noun.toLowerCase()} details.`);
|
else setError("Failed to load clip details.");
|
||||||
});
|
});
|
||||||
}, [id, noun, fetchDetails]);
|
}, [id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!item?.createdAt) return;
|
if (!clip?.createdAt) return;
|
||||||
const update = () => setTimeAgo(dateToTimeAgo(stringToDate(item.createdAt)));
|
const update = () => setTimeAgo(dateToTimeAgo(stringToDate(clip.createdAt)));
|
||||||
update();
|
update();
|
||||||
const interval = setInterval(update, 1000);
|
const interval = setInterval(update, 1000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [item]);
|
}, [clip]);
|
||||||
|
|
||||||
// ── Controls visibility ─────────────────────────────────────────────────
|
// ── Controls visibility ─────────────────────────────────────────────────
|
||||||
const scheduleHide = useCallback(() => {
|
const scheduleHide = useCallback(() => {
|
||||||
@@ -202,16 +187,16 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
if (notAuthenticated) {
|
if (notAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col items-center justify-center gap-4 text-center p-8">
|
<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-fields flex items-center justify-center">
|
<div className="w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center">
|
||||||
<Lock size={24} className="text-muted" />
|
<Lock size={24} className="text-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-2xl font-heading text-text-primary">Not authenticated</p>
|
<p className="text-lg font-semibold text-gray-900">Not authenticated</p>
|
||||||
<p className="text-sm text-text-secondary mt-1">You don't have access to this {noun.toLowerCase()}.</p>
|
<p className="text-sm text-gray-500 mt-1">You don't have access to this clip.</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
className="flex items-center gap-1.5 text-sm text-text-secondary hover:text-text-primary transition-colors duration-150"
|
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={16} />
|
||||||
Go back
|
Go back
|
||||||
@@ -224,7 +209,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
<div className="h-full flex flex-col p-4 gap-3">
|
<div className="h-full flex flex-col p-4 gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
className="flex items-center gap-1.5 text-sm text-text-secondary hover:text-text-primary flex-shrink-0 transition-colors duration-150"
|
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} />
|
<ArrowLeft size={16} />
|
||||||
Back
|
Back
|
||||||
@@ -233,7 +218,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
{/* Player */}
|
{/* Player */}
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="relative bg-text-primary rounded-xl overflow-hidden flex-1 min-h-0 select-none"
|
className="relative bg-black rounded-xl overflow-hidden flex-1 min-h-0 select-none"
|
||||||
onMouseMove={showControls}
|
onMouseMove={showControls}
|
||||||
onMouseLeave={() => isPlaying && setControlsVisible(false)}
|
onMouseLeave={() => isPlaying && setControlsVisible(false)}
|
||||||
onClick={togglePlay}
|
onClick={togglePlay}
|
||||||
@@ -242,10 +227,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
className="w-full h-full object-contain"
|
className="w-full h-full object-contain"
|
||||||
src={mediaUrl}
|
src={videoUrl}
|
||||||
// Send auth cookies with the cross-origin Range requests.
|
|
||||||
crossOrigin="use-credentials"
|
|
||||||
preload="metadata"
|
|
||||||
onPlay={() => { setIsPlaying(true); scheduleHide(); }}
|
onPlay={() => { setIsPlaying(true); scheduleHide(); }}
|
||||||
onPause={() => {
|
onPause={() => {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
@@ -253,19 +235,16 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
||||||
}}
|
}}
|
||||||
onTimeUpdate={handleTimeUpdate}
|
onTimeUpdate={handleTimeUpdate}
|
||||||
onLoadedMetadata={() => {
|
onLoadedMetadata={() => { if (videoRef.current) setDuration(videoRef.current.duration); }}
|
||||||
if (videoRef.current) setDuration(videoRef.current.duration);
|
|
||||||
setMediaLoaded(true);
|
|
||||||
}}
|
|
||||||
onWaiting={() => setIsWaiting(true)}
|
onWaiting={() => setIsWaiting(true)}
|
||||||
onCanPlay={() => setIsWaiting(false)}
|
onCanPlay={() => setIsWaiting(false)}
|
||||||
onError={(e) => setError(e.currentTarget.error?.message || "An error occurred.")}
|
onError={(e) => setError(e.currentTarget.error?.message || "An error occurred.")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Buffering spinner */}
|
{/* Buffering spinner */}
|
||||||
{isWaiting && mediaUrl && (
|
{isWaiting && videoUrl && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
<div className="w-11 h-11 rounded-full border-[3px] border-on-dark/20 border-t-on-dark animate-spin" />
|
<div className="w-11 h-11 rounded-full border-[3px] border-white/20 border-t-white animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -282,20 +261,20 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
{/* Scrub bar */}
|
{/* Scrub bar */}
|
||||||
<div
|
<div
|
||||||
ref={progressRef}
|
ref={progressRef}
|
||||||
className="relative h-1 rounded-full bg-on-dark/25 cursor-pointer mb-3 group/prog hover:h-1.5 transition-[height] duration-150"
|
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); }}
|
onMouseDown={(e) => { setIsDragging(true); seekTo(e.clientX); }}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-y-0 left-0 bg-on-dark/35 rounded-full" style={{ width: `${bufferedPct}%` }} />
|
<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-terracotta-light rounded-full" style={{ width: `${progressPct}%` }} />
|
<div className="absolute inset-y-0 left-0 bg-primary rounded-full" style={{ width: `${progressPct}%` }} />
|
||||||
<div
|
<div
|
||||||
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-on-dark rounded-full shadow opacity-0 group-hover/prog:opacity-100 transition-opacity"
|
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)` }}
|
style={{ left: `calc(${progressPct}% - 6px)` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Buttons row */}
|
{/* Buttons row */}
|
||||||
<div className="flex items-center gap-3 text-on-dark">
|
<div className="flex items-center gap-3 text-white">
|
||||||
<button onClick={togglePlay} className="hover:text-on-dark/70 transition-colors">
|
<button onClick={togglePlay} className="hover:text-white/70 transition-colors">
|
||||||
{isPlaying
|
{isPlaying
|
||||||
? <Pause size={20} fill="currentColor" />
|
? <Pause size={20} fill="currentColor" />
|
||||||
: <Play size={20} fill="currentColor" />}
|
: <Play size={20} fill="currentColor" />}
|
||||||
@@ -303,7 +282,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
|
|
||||||
{/* Volume — slider expands on hover */}
|
{/* Volume — slider expands on hover */}
|
||||||
<div className="flex items-center gap-1.5 group/vol">
|
<div className="flex items-center gap-1.5 group/vol">
|
||||||
<button onClick={toggleMute} className="hover:text-on-dark/70 transition-colors">
|
<button onClick={toggleMute} className="hover:text-white/70 transition-colors">
|
||||||
<VolumeIcon size={18} />
|
<VolumeIcon size={18} />
|
||||||
</button>
|
</button>
|
||||||
<div className="overflow-hidden w-0 group-hover/vol:w-16 transition-[width] duration-200">
|
<div className="overflow-hidden w-0 group-hover/vol:w-16 transition-[width] duration-200">
|
||||||
@@ -312,18 +291,18 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
min={0} max={1} step={0.02}
|
min={0} max={1} step={0.02}
|
||||||
value={effectiveVolume}
|
value={effectiveVolume}
|
||||||
onChange={handleVolumeChange}
|
onChange={handleVolumeChange}
|
||||||
className="w-16 accent-on-dark cursor-pointer"
|
className="w-16 accent-white cursor-pointer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="text-xs font-data tabular-nums text-on-dark/80">
|
<span className="text-xs font-mono tabular-nums text-white/80">
|
||||||
{formatTime(currentTime)} / {formatTime(duration)}
|
{formatTime(currentTime)} / {formatTime(duration)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
<button onClick={toggleFullscreen} className="hover:text-on-dark/70 transition-colors">
|
<button onClick={toggleFullscreen} className="hover:text-white/70 transition-colors">
|
||||||
{isFullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
|
{isFullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -331,20 +310,20 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="text-error text-sm mt-3">{error}</div>}
|
{error && <div className="text-red-500 text-sm mt-3">{error}</div>}
|
||||||
{!mediaLoaded && !error && (
|
{!videoUrl && !error && (
|
||||||
<div className="text-muted text-sm mt-3 flex items-center gap-2">
|
<div className="text-gray-400 text-sm mt-3 flex items-center gap-2">
|
||||||
<div className="w-3 h-3 rounded-full border-2 border-inactive border-t-muted animate-spin" />
|
<div className="w-3 h-3 rounded-full border-2 border-gray-300 border-t-gray-500 animate-spin" />
|
||||||
Loading video...
|
Loading video...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box className="p-4 flex-shrink-0">
|
<Box className="p-4 flex-shrink-0">
|
||||||
<p className="text-2xl font-heading text-text-primary">{item?.title || "(No Title)"}</p>
|
<p className="text-xl font-semibold text-gray-900">{clip?.title || "(No Title)"}</p>
|
||||||
<p className="text-sm text-text-secondary mt-1">{timeAgo}</p>
|
<p className="text-sm text-gray-500 mt-1">{timeAgo}</p>
|
||||||
</Box>
|
</Box>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MediaPlayer;
|
export default VideoPlayer;
|
||||||
311
frontend/src/pages/VodPlayer.tsx
Normal file
311
frontend/src/pages/VodPlayer.tsx
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
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 } from "../utils/api/client.ts";
|
||||||
|
import { getVodById, getVodBlob } from "../utils/api/vods.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,6 +1,4 @@
|
|||||||
import {config} from "../../config.ts";
|
const API_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
const API_URL = config.apiUrl;
|
|
||||||
|
|
||||||
class AuthError extends Error {
|
class AuthError extends Error {
|
||||||
constructor() { super("Not authenticated"); this.name = "AuthError"; }
|
constructor() { super("Not authenticated"); this.name = "AuthError"; }
|
||||||
|
|||||||
@@ -25,10 +25,16 @@ const getClipById = async (id: string): Promise<Clip | null> => {
|
|||||||
return result.data;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
// URL for the raw media stream. The <video> element loads this directly so the
|
const getVideoBlob = async (id: string): Promise<Blob> => {
|
||||||
// browser can issue HTTP Range requests (progressive loading + seeking) instead
|
const response = await fetch(API_URL + `/api/v1/clips/${id}/media`, { credentials: 'include' });
|
||||||
// of downloading the whole file up front.
|
|
||||||
const getClipMediaUrl = (id: string): string => API_URL + `/api/v1/clips/${id}/media`;
|
if (!response.ok) {
|
||||||
|
if (response.status === 401 || response.status === 403) throw new AuthError();
|
||||||
|
throw new Error(`Failed to fetch video ${id}: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.blob();
|
||||||
|
};
|
||||||
|
|
||||||
const patchClip = async (id: number, data: { title?: string; description?: string }): Promise<Clip> => {
|
const patchClip = async (id: number, data: { title?: string; description?: string }): Promise<Clip> => {
|
||||||
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
|
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
|
||||||
@@ -58,4 +64,4 @@ const deleteClip = async (id: number): Promise<void> => {
|
|||||||
if (result.status === 'error') throw new Error(`Failed to delete clip: ${result.message}`);
|
if (result.status === 'error') throw new Error(`Failed to delete clip: ${result.message}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export { getClips, getClipById, getClipMediaUrl, patchClip, deleteClip };
|
export { getClips, getClipById, getVideoBlob, patchClip, deleteClip };
|
||||||
|
|||||||
@@ -25,10 +25,16 @@ const getVodById = async (id: string): Promise<Vod | null> => {
|
|||||||
return result.data;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
// URL for the raw media stream. The <video> element loads this directly so the
|
const getVodBlob = async (id: string): Promise<Blob> => {
|
||||||
// browser can issue HTTP Range requests (progressive loading + seeking) instead
|
const response = await fetch(API_URL + `/api/v1/vods/${id}/media`, { credentials: 'include' });
|
||||||
// of downloading the whole file up front.
|
|
||||||
const getVodMediaUrl = (id: string): string => API_URL + `/api/v1/vods/${id}/media`;
|
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 patchVod = async (id: number, data: { title?: string; description?: string }): Promise<Vod> => {
|
||||||
const response = await fetch(API_URL + `/api/v1/vods/${id}`, {
|
const response = await fetch(API_URL + `/api/v1/vods/${id}`, {
|
||||||
@@ -58,4 +64,4 @@ const deleteVod = async (id: number): Promise<void> => {
|
|||||||
if (result.status === 'error') throw new Error(`Failed to delete vod: ${result.message}`);
|
if (result.status === 'error') throw new Error(`Failed to delete vod: ${result.message}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export { getVods, getVodById, getVodMediaUrl, patchVod, deleteVod };
|
export { getVods, getVodById, getVodBlob, patchVod, deleteVod };
|
||||||
|
|||||||
8
frontend/src/vite-env.d.ts
vendored
8
frontend/src/vite-env.d.ts
vendored
@@ -1,9 +1 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
interface Window {
|
|
||||||
_env_?: {
|
|
||||||
VITE_API_URL?: string;
|
|
||||||
VITE_FRONTEND_URL?: string;
|
|
||||||
VITE_GOOGLE_CLIENT_ID?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import { defineConfig, loadEnv } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import flowbiteReact from "flowbite-react/plugin/vite";
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => { // change to function form
|
export default defineConfig(({ mode }) => { // change to function form
|
||||||
const env = loadEnv(mode, process.cwd(), '') // load all env vars
|
const env = loadEnv(mode, process.cwd(), '') // load all env vars
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss(), flowbiteReact()],
|
||||||
preview: {
|
preview: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
|
|||||||
@@ -10,11 +10,6 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
|||||||
@EnableAsync
|
@EnableAsync
|
||||||
public class VodSystemApplication {
|
public class VodSystemApplication {
|
||||||
|
|
||||||
/**
|
|
||||||
* Application entry point; boots the Spring context.
|
|
||||||
*
|
|
||||||
* @param args command-line arguments passed to Spring Boot
|
|
||||||
*/
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
SpringApplication.run(VodSystemApplication.class, args);
|
SpringApplication.run(VodSystemApplication.class, args);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,11 +14,6 @@ import java.util.concurrent.Executor;
|
|||||||
public class AsyncConfig implements AsyncConfigurer {
|
public class AsyncConfig implements AsyncConfigurer {
|
||||||
private static final Logger logger = LoggerFactory.getLogger(AsyncConfig.class);
|
private static final Logger logger = LoggerFactory.getLogger(AsyncConfig.class);
|
||||||
|
|
||||||
/**
|
|
||||||
* Provides the bounded thread pool that runs ffmpeg-backed {@code @Async} media tasks.
|
|
||||||
*
|
|
||||||
* @return the configured {@link Executor} registered as {@code ffmpegTaskExecutor}
|
|
||||||
*/
|
|
||||||
@Bean(name = "ffmpegTaskExecutor")
|
@Bean(name = "ffmpegTaskExecutor")
|
||||||
public Executor taskExecutor() {
|
public Executor taskExecutor() {
|
||||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||||
@@ -30,11 +25,6 @@ public class AsyncConfig implements AsyncConfigurer {
|
|||||||
return executor;
|
return executor;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Supplies the handler that logs uncaught exceptions from void-returning {@code @Async} methods.
|
|
||||||
*
|
|
||||||
* @return the async uncaught-exception handler
|
|
||||||
*/
|
|
||||||
@Override
|
@Override
|
||||||
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
|
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
|
||||||
return (ex, method, params) ->
|
return (ex, method, params) ->
|
||||||
|
|||||||
@@ -12,11 +12,6 @@ public class CorsConfig {
|
|||||||
@Value("${frontend.url}")
|
@Value("${frontend.url}")
|
||||||
private String frontendUrl;
|
private String frontendUrl;
|
||||||
|
|
||||||
/**
|
|
||||||
* Configures CORS for the API, allowing credentialed requests from the configured frontend origin.
|
|
||||||
*
|
|
||||||
* @return a {@link WebMvcConfigurer} that registers the CORS mappings for {@code /api/v1/**}
|
|
||||||
*/
|
|
||||||
@Bean
|
@Bean
|
||||||
public WebMvcConfigurer corsConfigurer() {
|
public WebMvcConfigurer corsConfigurer() {
|
||||||
return new WebMvcConfigurer() {
|
return new WebMvcConfigurer() {
|
||||||
|
|||||||
@@ -22,14 +22,6 @@ public class SecurityConfig {
|
|||||||
this.jwtFilter = jwtFilter;
|
this.jwtFilter = jwtFilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the security filter chain: stateless sessions, CSRF disabled, the {@link JwtFilter}
|
|
||||||
* installed, and per-endpoint authorization rules.
|
|
||||||
*
|
|
||||||
* @param http the HTTP security builder to configure
|
|
||||||
* @return the built {@link SecurityFilterChain}
|
|
||||||
* @throws Exception if the security configuration cannot be built
|
|
||||||
*/
|
|
||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||||
http
|
http
|
||||||
@@ -38,9 +30,8 @@ public class SecurityConfig {
|
|||||||
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
|
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers(HttpMethod.OPTIONS, "/api/v1/**").permitAll()
|
.requestMatchers(HttpMethod.OPTIONS, "/api/v1/**").permitAll()
|
||||||
.requestMatchers("/api/v1/media/compress").permitAll() // compression available without authenticated
|
|
||||||
.requestMatchers("/api/v1/jobs/**").permitAll() // same with jobs
|
|
||||||
.requestMatchers("/api/v1/media/**").authenticated()
|
.requestMatchers("/api/v1/media/**").authenticated()
|
||||||
|
.requestMatchers("/api/v1/jobs/**").authenticated()
|
||||||
.requestMatchers("/api/v1/users/me", "/api/v1/users/logout").authenticated()
|
.requestMatchers("/api/v1/users/me", "/api/v1/users/logout").authenticated()
|
||||||
.requestMatchers("/api/v1/users/login").permitAll()
|
.requestMatchers("/api/v1/users/login").permitAll()
|
||||||
.requestMatchers("/api/v1/stream/start", "/api/v1/stream/stop", "/api/v1/stream/heartbeat").permitAll()
|
.requestMatchers("/api/v1/stream/start", "/api/v1/stream/stop", "/api/v1/stream/heartbeat").permitAll()
|
||||||
|
|||||||
@@ -6,12 +6,17 @@ import com.ddf.vodsystem.controllers.dto.ClipUpdateRequest;
|
|||||||
import com.ddf.vodsystem.entities.Clip;
|
import com.ddf.vodsystem.entities.Clip;
|
||||||
import com.ddf.vodsystem.services.ClipService;
|
import com.ddf.vodsystem.services.ClipService;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.http.*;
|
import org.springframework.core.io.support.ResourceRegion;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpRange;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.MediaTypeFactory;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/clips")
|
@RequestMapping("/api/v1/clips")
|
||||||
@@ -24,11 +29,6 @@ public class ClipController {
|
|||||||
this.clipService = clipService;
|
this.clipService = clipService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Lists all clips belonging to the authenticated user.
|
|
||||||
*
|
|
||||||
* @return {@code 200 OK} wrapping the user's clips as {@link ClipResponse} DTOs
|
|
||||||
*/
|
|
||||||
@GetMapping("")
|
@GetMapping("")
|
||||||
public ResponseEntity<APIResponse<List<ClipResponse>>> getClips() {
|
public ResponseEntity<APIResponse<List<ClipResponse>>> getClips() {
|
||||||
List<Clip> clips = clipService.getClipsByUser();
|
List<Clip> clips = clipService.getClipsByUser();
|
||||||
@@ -44,12 +44,6 @@ public class ClipController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves a single clip owned by the authenticated user.
|
|
||||||
*
|
|
||||||
* @param id the ID of the clip to retrieve
|
|
||||||
* @return {@code 200 OK} wrapping the clip as a {@link ClipResponse} DTO
|
|
||||||
*/
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
public ResponseEntity<APIResponse<ClipResponse>> getClipById(@PathVariable Long id) {
|
public ResponseEntity<APIResponse<ClipResponse>> getClipById(@PathVariable Long id) {
|
||||||
Clip clip = clipService.getClipById(id);
|
Clip clip = clipService.getClipById(id);
|
||||||
@@ -63,13 +57,6 @@ public class ClipController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies a partial update to a clip's metadata; null fields are left unchanged.
|
|
||||||
*
|
|
||||||
* @param id the ID of the clip to update
|
|
||||||
* @param updateFields the title and/or description to overwrite
|
|
||||||
* @return {@code 200 OK} wrapping the updated clip as a {@link ClipResponse} DTO
|
|
||||||
*/
|
|
||||||
@PatchMapping("/{id}")
|
@PatchMapping("/{id}")
|
||||||
public ResponseEntity<APIResponse<ClipResponse>> updateClip(@PathVariable Long id,
|
public ResponseEntity<APIResponse<ClipResponse>> updateClip(@PathVariable Long id,
|
||||||
@RequestBody ClipUpdateRequest updateFields) {
|
@RequestBody ClipUpdateRequest updateFields) {
|
||||||
@@ -81,12 +68,6 @@ public class ClipController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a clip and its associated files.
|
|
||||||
*
|
|
||||||
* @param id the ID of the clip to delete
|
|
||||||
* @return {@code 200 OK} with a confirmation message
|
|
||||||
*/
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
public ResponseEntity<APIResponse<String>> deleteClip(@PathVariable Long id) {
|
public ResponseEntity<APIResponse<String>> deleteClip(@PathVariable Long id) {
|
||||||
clipService.deleteClip(id);
|
clipService.deleteClip(id);
|
||||||
@@ -100,39 +81,42 @@ public class ClipController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@GetMapping("/{id}/media")
|
||||||
* Streams the clip's video file inline, supporting HTTP range requests.
|
public ResponseEntity<?> downloadClip(@PathVariable Long id, @RequestHeader HttpHeaders headers) throws IOException {
|
||||||
*
|
|
||||||
* @param id the ID of the clip to download
|
|
||||||
* @return {@code 200 OK} with the video resource, or {@code 404 Not Found} if the file is missing
|
|
||||||
* @throws IOException if the resource's content length cannot be read
|
|
||||||
*/
|
|
||||||
@GetMapping(value = "/{id}/media", produces = MediaType.ALL_VALUE)
|
|
||||||
public ResponseEntity<Resource> downloadClip(@PathVariable Long id) throws IOException {
|
|
||||||
Resource resource = clipService.downloadClip(id);
|
Resource resource = clipService.downloadClip(id);
|
||||||
|
|
||||||
if (resource == null || !resource.exists()) {
|
if (resource == null || !resource.exists()) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<HttpRange> ranges = headers.getRange();
|
||||||
|
|
||||||
|
if (!ranges.isEmpty()) {
|
||||||
|
ResourceRegion region = createResourceRegion(resource, ranges.getFirst());
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
|
||||||
|
.header(HttpHeaders.ACCEPT_RANGES, "bytes")
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
|
||||||
|
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
|
||||||
|
.body(region);
|
||||||
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok()
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
.header(HttpHeaders.ACCEPT_RANGES, "bytes")
|
||||||
String.format(FILENAME_HEADER, resource.getFilename()))
|
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
|
||||||
.contentType(MediaTypeFactory.getMediaType(resource)
|
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
|
||||||
.orElse(MediaType.APPLICATION_OCTET_STREAM))
|
|
||||||
.contentLength(resource.contentLength())
|
|
||||||
.body(resource);
|
.body(resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private ResourceRegion createResourceRegion(Resource resource, HttpRange range) throws IOException {
|
||||||
* Streams the clip's thumbnail image with a public cache policy.
|
long contentLength = resource.contentLength();
|
||||||
*
|
long start = range.getRangeStart(contentLength);
|
||||||
* @param id the ID of the clip whose thumbnail to download
|
long end = range.getRangeEnd(contentLength);
|
||||||
* @return {@code 200 OK} with the thumbnail resource, or {@code 404 Not Found} if the file is missing
|
return new ResourceRegion(resource, start, end - start + 1);
|
||||||
* @throws IOException if the resource's last-modified time cannot be read
|
}
|
||||||
*/
|
|
||||||
@GetMapping("/{id}/thumbnail")
|
@GetMapping("/{id}/thumbnail")
|
||||||
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) throws IOException {
|
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) {
|
||||||
Resource resource = clipService.downloadThumbnail(id);
|
Resource resource = clipService.downloadThumbnail(id);
|
||||||
|
|
||||||
if (resource == null || !resource.exists()) {
|
if (resource == null || !resource.exists()) {
|
||||||
@@ -140,8 +124,6 @@ public class ClipController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok()
|
||||||
.cacheControl(CacheControl.maxAge(2, TimeUnit.DAYS).cachePublic())
|
|
||||||
.lastModified(resource.lastModified())
|
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
|
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
|
||||||
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
|
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
|
||||||
.body(resource);
|
.body(resource);
|
||||||
|
|||||||
@@ -18,12 +18,6 @@ public class GlobalExceptionHandler {
|
|||||||
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||||
private static final String ERROR = "error";
|
private static final String ERROR = "error";
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles malformed multipart requests, returning {@code 400 Bad Request}.
|
|
||||||
*
|
|
||||||
* @param ex the thrown exception
|
|
||||||
* @return an error {@link APIResponse}
|
|
||||||
*/
|
|
||||||
@ExceptionHandler({ MultipartException.class })
|
@ExceptionHandler({ MultipartException.class })
|
||||||
public ResponseEntity<APIResponse<Void>> handleMultipartException(MultipartException ex) {
|
public ResponseEntity<APIResponse<Void>> handleMultipartException(MultipartException ex) {
|
||||||
logger.error("MultipartException: {}", ex.getMessage());
|
logger.error("MultipartException: {}", ex.getMessage());
|
||||||
@@ -31,12 +25,6 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
|
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles requests missing a required file part, returning {@code 400 Bad Request}.
|
|
||||||
*
|
|
||||||
* @param ex the thrown exception
|
|
||||||
* @return an error {@link APIResponse}
|
|
||||||
*/
|
|
||||||
@ExceptionHandler({ MissingServletRequestPartException.class })
|
@ExceptionHandler({ MissingServletRequestPartException.class })
|
||||||
public ResponseEntity<APIResponse<Void>> handleMissingPart(MissingServletRequestPartException ex) {
|
public ResponseEntity<APIResponse<Void>> handleMissingPart(MissingServletRequestPartException ex) {
|
||||||
logger.error("MissingServletRequestPartException: {}", ex.getMessage());
|
logger.error("MissingServletRequestPartException: {}", ex.getMessage());
|
||||||
@@ -44,12 +32,6 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
|
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles unsupported request content types, returning {@code 415 Unsupported Media Type}.
|
|
||||||
*
|
|
||||||
* @param ex the thrown exception
|
|
||||||
* @return an error {@link APIResponse}
|
|
||||||
*/
|
|
||||||
@ExceptionHandler({ HttpMediaTypeNotSupportedException.class })
|
@ExceptionHandler({ HttpMediaTypeNotSupportedException.class })
|
||||||
public ResponseEntity<APIResponse<Void>> handleUnsupportedMediaType(HttpMediaTypeNotSupportedException ex) {
|
public ResponseEntity<APIResponse<Void>> handleUnsupportedMediaType(HttpMediaTypeNotSupportedException ex) {
|
||||||
logger.error("HttpMediaTypeNotSupportedException: {}", ex.getMessage());
|
logger.error("HttpMediaTypeNotSupportedException: {}", ex.getMessage());
|
||||||
@@ -57,12 +39,6 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).body(response);
|
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).body(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles invalid arguments, returning {@code 400 Bad Request} with the exception message.
|
|
||||||
*
|
|
||||||
* @param ex the thrown exception
|
|
||||||
* @return an error {@link APIResponse}
|
|
||||||
*/
|
|
||||||
@ExceptionHandler(IllegalArgumentException.class)
|
@ExceptionHandler(IllegalArgumentException.class)
|
||||||
public ResponseEntity<APIResponse<Void>> handleIllegalArgument(IllegalArgumentException ex) {
|
public ResponseEntity<APIResponse<Void>> handleIllegalArgument(IllegalArgumentException ex) {
|
||||||
logger.error("IllegalArgumentException: {}", ex.getMessage());
|
logger.error("IllegalArgumentException: {}", ex.getMessage());
|
||||||
@@ -70,12 +46,6 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
|
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles lookups for a non-existent job, returning {@code 404 Not Found}.
|
|
||||||
*
|
|
||||||
* @param ex the thrown exception
|
|
||||||
* @return an error {@link APIResponse}
|
|
||||||
*/
|
|
||||||
@ExceptionHandler(JobNotFound.class)
|
@ExceptionHandler(JobNotFound.class)
|
||||||
public ResponseEntity<APIResponse<Void>> handleFileNotFound(JobNotFound ex) {
|
public ResponseEntity<APIResponse<Void>> handleFileNotFound(JobNotFound ex) {
|
||||||
logger.error("JobNotFound: {}", ex.getMessage());
|
logger.error("JobNotFound: {}", ex.getMessage());
|
||||||
@@ -83,12 +53,6 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles requests for a job that is still running, returning {@code 202 Accepted}.
|
|
||||||
*
|
|
||||||
* @param ex the thrown exception
|
|
||||||
* @return an error {@link APIResponse}
|
|
||||||
*/
|
|
||||||
@ExceptionHandler(JobNotFinished.class)
|
@ExceptionHandler(JobNotFinished.class)
|
||||||
public ResponseEntity<APIResponse<Void>> handleJobNotFinished(JobNotFinished ex) {
|
public ResponseEntity<APIResponse<Void>> handleJobNotFinished(JobNotFinished ex) {
|
||||||
logger.error("JobNotFinished: {}", ex.getMessage());
|
logger.error("JobNotFinished: {}", ex.getMessage());
|
||||||
@@ -96,12 +60,6 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.ACCEPTED).body(response);
|
return ResponseEntity.status(HttpStatus.ACCEPTED).body(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles ffmpeg processing failures, returning {@code 500 Internal Server Error}.
|
|
||||||
*
|
|
||||||
* @param ex the thrown exception
|
|
||||||
* @return an error {@link APIResponse}
|
|
||||||
*/
|
|
||||||
@ExceptionHandler(FFMPEGException.class)
|
@ExceptionHandler(FFMPEGException.class)
|
||||||
public ResponseEntity<APIResponse<Void>> handleFFMPEGException(FFMPEGException ex) {
|
public ResponseEntity<APIResponse<Void>> handleFFMPEGException(FFMPEGException ex) {
|
||||||
logger.error("FFMPEGException: {}", ex.getMessage());
|
logger.error("FFMPEGException: {}", ex.getMessage());
|
||||||
@@ -109,12 +67,6 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles unauthenticated or unauthorized access, returning {@code 401 Unauthorized}.
|
|
||||||
*
|
|
||||||
* @param ex the thrown exception
|
|
||||||
* @return an error {@link APIResponse}
|
|
||||||
*/
|
|
||||||
@ExceptionHandler(NotAuthenticated.class)
|
@ExceptionHandler(NotAuthenticated.class)
|
||||||
public ResponseEntity<APIResponse<Void>> handleNotAuthenticated(NotAuthenticated ex) {
|
public ResponseEntity<APIResponse<Void>> handleNotAuthenticated(NotAuthenticated ex) {
|
||||||
logger.error("NotAuthenticated: {}", ex.getMessage());
|
logger.error("NotAuthenticated: {}", ex.getMessage());
|
||||||
@@ -122,12 +74,6 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response);
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles an unrecognised stream key, returning {@code 404 Not Found}.
|
|
||||||
*
|
|
||||||
* @param ex the thrown exception
|
|
||||||
* @return an error {@link APIResponse}
|
|
||||||
*/
|
|
||||||
@ExceptionHandler(KeyNotFound.class)
|
@ExceptionHandler(KeyNotFound.class)
|
||||||
public ResponseEntity<APIResponse<Void>> handleKeyNotFound(KeyNotFound ex) {
|
public ResponseEntity<APIResponse<Void>> handleKeyNotFound(KeyNotFound ex) {
|
||||||
logger.error("KeyNotFound: {}", ex.getMessage());
|
logger.error("KeyNotFound: {}", ex.getMessage());
|
||||||
@@ -135,12 +81,6 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles an attempt to start a stream while one is already active, returning {@code 409 Conflict}.
|
|
||||||
*
|
|
||||||
* @param ex the thrown exception
|
|
||||||
* @return an error {@link APIResponse}
|
|
||||||
*/
|
|
||||||
@ExceptionHandler(AlreadyStreaming.class)
|
@ExceptionHandler(AlreadyStreaming.class)
|
||||||
public ResponseEntity<APIResponse<Void>> handleAlreadyStreaming(AlreadyStreaming ex) {
|
public ResponseEntity<APIResponse<Void>> handleAlreadyStreaming(AlreadyStreaming ex) {
|
||||||
logger.error("AlreadyStreaming: {}", ex.getMessage());
|
logger.error("AlreadyStreaming: {}", ex.getMessage());
|
||||||
@@ -148,12 +88,6 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.CONFLICT).body(response);
|
return ResponseEntity.status(HttpStatus.CONFLICT).body(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles file storage failures, returning {@code 500 Internal Server Error}.
|
|
||||||
*
|
|
||||||
* @param e the thrown exception
|
|
||||||
* @return an error {@link APIResponse}
|
|
||||||
*/
|
|
||||||
@ExceptionHandler(StorageException.class)
|
@ExceptionHandler(StorageException.class)
|
||||||
public ResponseEntity<APIResponse<Void>> handleStorage(StorageException e) {
|
public ResponseEntity<APIResponse<Void>> handleStorage(StorageException e) {
|
||||||
logger.error("Storage operation failed", e);
|
logger.error("Storage operation failed", e);
|
||||||
@@ -161,12 +95,6 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles lookups for a non-existent clip, returning {@code 404 Not Found}.
|
|
||||||
*
|
|
||||||
* @param ex the thrown exception
|
|
||||||
* @return an error {@link APIResponse}
|
|
||||||
*/
|
|
||||||
@ExceptionHandler(ClipNotFound.class)
|
@ExceptionHandler(ClipNotFound.class)
|
||||||
public ResponseEntity<APIResponse<Void>> handleClipNotFound(ClipNotFound ex) {
|
public ResponseEntity<APIResponse<Void>> handleClipNotFound(ClipNotFound ex) {
|
||||||
logger.error("ClipNotFound: {}", ex.getMessage());
|
logger.error("ClipNotFound: {}", ex.getMessage());
|
||||||
@@ -174,25 +102,6 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles a clip whose media file exists but is not readable, returning {@code 503 Service Unavailable}.
|
|
||||||
*
|
|
||||||
* @param ex the thrown exception
|
|
||||||
* @return an error {@link APIResponse}
|
|
||||||
*/
|
|
||||||
@ExceptionHandler(ClipMediaUnavailable.class)
|
|
||||||
public ResponseEntity<APIResponse<Void>> handleClipMediaUnavailable(ClipMediaUnavailable ex) {
|
|
||||||
logger.error("ClipMediaUnavailable (clip {}): {}", ex.getClipId(), ex.getMessage());
|
|
||||||
APIResponse<Void> response = new APIResponse<>(ERROR, "Clip media is currently unavailable", null);
|
|
||||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles lookups for a non-existent VoD, returning {@code 404 Not Found}.
|
|
||||||
*
|
|
||||||
* @param ex the thrown exception
|
|
||||||
* @return an error {@link APIResponse}
|
|
||||||
*/
|
|
||||||
@ExceptionHandler(VodNotFound.class)
|
@ExceptionHandler(VodNotFound.class)
|
||||||
public ResponseEntity<APIResponse<Void>> handleVodNotFound(VodNotFound ex) {
|
public ResponseEntity<APIResponse<Void>> handleVodNotFound(VodNotFound ex) {
|
||||||
logger.error("VodNotFound: {}", ex.getMessage());
|
logger.error("VodNotFound: {}", ex.getMessage());
|
||||||
@@ -200,25 +109,6 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles a VoD whose media file exists but is not readable, returning {@code 503 Service Unavailable}.
|
|
||||||
*
|
|
||||||
* @param ex the thrown exception
|
|
||||||
* @return an error {@link APIResponse}
|
|
||||||
*/
|
|
||||||
@ExceptionHandler(VodMediaUnavailable.class)
|
|
||||||
public ResponseEntity<APIResponse<Void>> handleVodMediaUnavailable(VodMediaUnavailable ex) {
|
|
||||||
logger.error("VodMediaUnavailable (vod {}): {}", ex.getVodId(), ex.getMessage());
|
|
||||||
APIResponse<Void> response = new APIResponse<>(ERROR, "Vod media is currently unavailable", null);
|
|
||||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles actions that require an active stream when none exists, returning {@code 409 Conflict}.
|
|
||||||
*
|
|
||||||
* @param ex the thrown exception
|
|
||||||
* @return an error {@link APIResponse}
|
|
||||||
*/
|
|
||||||
@ExceptionHandler(NotStreaming.class)
|
@ExceptionHandler(NotStreaming.class)
|
||||||
public ResponseEntity<APIResponse<Void>> handleNotStreaming(NotStreaming ex) {
|
public ResponseEntity<APIResponse<Void>> handleNotStreaming(NotStreaming ex) {
|
||||||
logger.error("NotStreaming: {}", ex.getMessage());
|
logger.error("NotStreaming: {}", ex.getMessage());
|
||||||
@@ -226,12 +116,6 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.CONFLICT).body(response);
|
return ResponseEntity.status(HttpStatus.CONFLICT).body(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles lookups for a non-existent marker, returning {@code 404 Not Found}.
|
|
||||||
*
|
|
||||||
* @param ex the thrown exception
|
|
||||||
* @return an error {@link APIResponse}
|
|
||||||
*/
|
|
||||||
@ExceptionHandler(MarkerNotFound.class)
|
@ExceptionHandler(MarkerNotFound.class)
|
||||||
public ResponseEntity<APIResponse<Void>> handleMarkerNotFound(MarkerNotFound ex) {
|
public ResponseEntity<APIResponse<Void>> handleMarkerNotFound(MarkerNotFound ex) {
|
||||||
logger.error("MarkerNotFound: {}", ex.getMessage());
|
logger.error("MarkerNotFound: {}", ex.getMessage());
|
||||||
|
|||||||
@@ -28,13 +28,6 @@ public class JobController {
|
|||||||
this.jobRegistryService = jobRegistryService;
|
this.jobRegistryService = jobRegistryService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves the status and progress of an asynchronous job.
|
|
||||||
*
|
|
||||||
* @param uuid the UUID of the job to look up
|
|
||||||
* @return {@code 200 OK} wrapping the job's state as a {@link JobResponse} DTO
|
|
||||||
* @throws com.ddf.vodsystem.exceptions.JobNotFound if no job with {@code uuid} exists
|
|
||||||
*/
|
|
||||||
@GetMapping("/{uuid}")
|
@GetMapping("/{uuid}")
|
||||||
public ResponseEntity<APIResponse<JobResponse>> getJob(@PathVariable String uuid) {
|
public ResponseEntity<APIResponse<JobResponse>> getJob(@PathVariable String uuid) {
|
||||||
Job job = jobRegistryService.getJob(uuid);
|
Job job = jobRegistryService.getJob(uuid);
|
||||||
@@ -54,15 +47,6 @@ public class JobController {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Downloads the output file produced by a completed job.
|
|
||||||
*
|
|
||||||
* @param uuid the UUID of the job whose output to download
|
|
||||||
* @return {@code 200 OK} with the output file as a downloadable resource
|
|
||||||
* @throws com.ddf.vodsystem.exceptions.JobNotFound if no job with {@code uuid} exists
|
|
||||||
* @throws NotReadyException if the job has not yet produced a downloadable file
|
|
||||||
* @throws ClipNotFound if the job's output file no longer exists on disk
|
|
||||||
*/
|
|
||||||
@GetMapping("/{uuid}/download")
|
@GetMapping("/{uuid}/download")
|
||||||
public ResponseEntity<Resource> downloadJobFile(@PathVariable String uuid) {
|
public ResponseEntity<Resource> downloadJobFile(@PathVariable String uuid) {
|
||||||
Job job = jobRegistryService.getJob(uuid);
|
Job job = jobRegistryService.getJob(uuid);
|
||||||
|
|||||||
@@ -22,11 +22,6 @@ public class MarkerController {
|
|||||||
this.markerService = markerService;
|
this.markerService = markerService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Lists all markers belonging to the authenticated user.
|
|
||||||
*
|
|
||||||
* @return {@code 200 OK} wrapping the user's markers as {@link MarkerResponse} DTOs
|
|
||||||
*/
|
|
||||||
@GetMapping("")
|
@GetMapping("")
|
||||||
public ResponseEntity<APIResponse<List<MarkerResponse>>> getMarkers() {
|
public ResponseEntity<APIResponse<List<MarkerResponse>>> getMarkers() {
|
||||||
List<MarkerResponse> markers = markerService.getUserMarkers().stream()
|
List<MarkerResponse> markers = markerService.getUserMarkers().stream()
|
||||||
@@ -38,12 +33,6 @@ public class MarkerController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves a single marker owned by the authenticated user.
|
|
||||||
*
|
|
||||||
* @param id the ID of the marker to retrieve
|
|
||||||
* @return {@code 200 OK} wrapping the marker as a {@link MarkerResponse} DTO
|
|
||||||
*/
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
public ResponseEntity<APIResponse<MarkerResponse>> getMarkerById(@PathVariable Long id) {
|
public ResponseEntity<APIResponse<MarkerResponse>> getMarkerById(@PathVariable Long id) {
|
||||||
Marker marker = markerService.getMarkerById(id);
|
Marker marker = markerService.getMarkerById(id);
|
||||||
@@ -53,12 +42,6 @@ public class MarkerController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a marker at the current moment on the user's active stream.
|
|
||||||
*
|
|
||||||
* @param request the marker payload carrying the marker message
|
|
||||||
* @return {@code 200 OK} wrapping the created marker as a {@link MarkerResponse} DTO
|
|
||||||
*/
|
|
||||||
@PostMapping("")
|
@PostMapping("")
|
||||||
public ResponseEntity<APIResponse<MarkerResponse>> createMarker(@Valid @RequestBody MarkerCreateRequest request) {
|
public ResponseEntity<APIResponse<MarkerResponse>> createMarker(@Valid @RequestBody MarkerCreateRequest request) {
|
||||||
Marker marker = markerService.create(request.message());
|
Marker marker = markerService.create(request.message());
|
||||||
@@ -68,12 +51,6 @@ public class MarkerController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a marker owned by the authenticated user.
|
|
||||||
*
|
|
||||||
* @param id the ID of the marker to delete
|
|
||||||
* @return {@code 200 OK} with a confirmation message
|
|
||||||
*/
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
public ResponseEntity<APIResponse<String>> deleteMarker(@PathVariable Long id) {
|
public ResponseEntity<APIResponse<String>> deleteMarker(@PathVariable Long id) {
|
||||||
markerService.deleteMarker(id);
|
markerService.deleteMarker(id);
|
||||||
|
|||||||
@@ -26,14 +26,6 @@ public class MediaController {
|
|||||||
this.mediaService = mediaService;
|
this.mediaService = mediaService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts an asynchronous job to compress an uploaded video file.
|
|
||||||
*
|
|
||||||
* @param file the uploaded video to compress
|
|
||||||
* @param options trim window, resolution, fps, and target size for the output
|
|
||||||
* @return {@code 200 OK} wrapping the new job's UUID for polling
|
|
||||||
* @throws IOException if saving the upload to temporary storage fails
|
|
||||||
*/
|
|
||||||
@PostMapping(value = "/compress", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
@PostMapping(value = "/compress", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
public ResponseEntity<APIResponse<UUIDResponse>> compress(
|
public ResponseEntity<APIResponse<UUIDResponse>> compress(
|
||||||
@RequestParam("file")MultipartFile file,
|
@RequestParam("file")MultipartFile file,
|
||||||
@@ -48,13 +40,6 @@ public class MediaController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts an asynchronous job to save a section of the user's stream between two timestamps as a VoD.
|
|
||||||
*
|
|
||||||
* @param saveSectionRequest the start/end timestamps and optional title/description
|
|
||||||
* @return {@code 200 OK} wrapping the new job's UUID for polling
|
|
||||||
* @throws IOException if reading the stream segments fails
|
|
||||||
*/
|
|
||||||
@PostMapping("/save")
|
@PostMapping("/save")
|
||||||
public ResponseEntity<APIResponse<UUIDResponse>> save(
|
public ResponseEntity<APIResponse<UUIDResponse>> save(
|
||||||
@RequestBody SaveSectionRequest saveSectionRequest) throws IOException {
|
@RequestBody SaveSectionRequest saveSectionRequest) throws IOException {
|
||||||
@@ -70,13 +55,6 @@ public class MediaController {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts an asynchronous job to save the section of the user's stream between two markers as a VoD.
|
|
||||||
*
|
|
||||||
* @param saveSectionByMarkerRequest the start/end marker IDs and optional title/description
|
|
||||||
* @return {@code 200 OK} wrapping the new job's UUID for polling
|
|
||||||
* @throws IOException if reading the stream segments fails
|
|
||||||
*/
|
|
||||||
@PostMapping("/save/markers")
|
@PostMapping("/save/markers")
|
||||||
public ResponseEntity<APIResponse<UUIDResponse>> saveByMarkers(
|
public ResponseEntity<APIResponse<UUIDResponse>> saveByMarkers(
|
||||||
@Valid @RequestBody SaveSectionByMarkerRequest saveSectionByMarkerRequest) throws IOException {
|
@Valid @RequestBody SaveSectionByMarkerRequest saveSectionByMarkerRequest) throws IOException {
|
||||||
@@ -92,13 +70,6 @@ public class MediaController {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts an asynchronous job to save the last few seconds of the user's stream as a clip.
|
|
||||||
*
|
|
||||||
* @param clipSectionRequest the clip duration in seconds and optional title/description
|
|
||||||
* @return {@code 200 OK} wrapping the new job's UUID for polling
|
|
||||||
* @throws IOException if reading the stream segments fails
|
|
||||||
*/
|
|
||||||
@PostMapping("/clip")
|
@PostMapping("/clip")
|
||||||
public ResponseEntity<APIResponse<UUIDResponse>> clip(
|
public ResponseEntity<APIResponse<UUIDResponse>> clip(
|
||||||
@RequestBody ClipSectionRequest clipSectionRequest) throws IOException {
|
@RequestBody ClipSectionRequest clipSectionRequest) throws IOException {
|
||||||
|
|||||||
@@ -24,14 +24,6 @@ public class StreamController {
|
|||||||
this.streamService = streamService;
|
this.streamService = streamService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts a new stream for the user owning the given stream key.
|
|
||||||
* <p>
|
|
||||||
* Called by nginx via its {@code on_publish} RTMP callback when a broadcast begins.
|
|
||||||
*
|
|
||||||
* @param streamKey the RTMP stream key identifying the broadcasting user
|
|
||||||
* @return {@code 200 OK} wrapping the newly started {@link Stream}
|
|
||||||
*/
|
|
||||||
// Called by nginx on_publish
|
// Called by nginx on_publish
|
||||||
@PostMapping("/start")
|
@PostMapping("/start")
|
||||||
public ResponseEntity<APIResponse<Stream>> startStream(@RequestParam("name") String streamKey) {
|
public ResponseEntity<APIResponse<Stream>> startStream(@RequestParam("name") String streamKey) {
|
||||||
@@ -40,14 +32,6 @@ public class StreamController {
|
|||||||
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream started", stream));
|
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream started", stream));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Ends the active stream for the user owning the given stream key.
|
|
||||||
* <p>
|
|
||||||
* Called by nginx via its {@code on_publish_done} RTMP callback when a broadcast stops.
|
|
||||||
*
|
|
||||||
* @param streamKey the RTMP stream key identifying the broadcasting user
|
|
||||||
* @return {@code 200 OK} confirming the stream was ended
|
|
||||||
*/
|
|
||||||
// Called by nginx on_publish_done
|
// Called by nginx on_publish_done
|
||||||
@PostMapping("/stop")
|
@PostMapping("/stop")
|
||||||
public ResponseEntity<APIResponse<Void>> stopStream(@RequestParam("name") String streamKey) {
|
public ResponseEntity<APIResponse<Void>> stopStream(@RequestParam("name") String streamKey) {
|
||||||
@@ -56,14 +40,6 @@ public class StreamController {
|
|||||||
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream ended", null));
|
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream ended", null));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Records a liveness heartbeat for the active stream, keeping it from being reaped as stale.
|
|
||||||
* <p>
|
|
||||||
* Called periodically by nginx via its {@code on_update} RTMP callback.
|
|
||||||
*
|
|
||||||
* @param streamKey the RTMP stream key identifying the broadcasting user
|
|
||||||
* @return {@code 200 OK} confirming the heartbeat was recorded
|
|
||||||
*/
|
|
||||||
// Called by nginx on_update
|
// Called by nginx on_update
|
||||||
@PostMapping("/heartbeat")
|
@PostMapping("/heartbeat")
|
||||||
public ResponseEntity<APIResponse<Void>> heartbeat(@RequestParam("name") String streamKey) {
|
public ResponseEntity<APIResponse<Void>> heartbeat(@RequestParam("name") String streamKey) {
|
||||||
@@ -71,24 +47,12 @@ public class StreamController {
|
|||||||
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Heartbeat recorded", null));
|
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Heartbeat recorded", null));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves the stream history for the given user.
|
|
||||||
*
|
|
||||||
* @param userId the ID of the user whose stream history to retrieve; must be the authenticated user
|
|
||||||
* @return {@code 200 OK} wrapping the user's past and current {@link Stream}s
|
|
||||||
*/
|
|
||||||
@GetMapping("/history/{userId}")
|
@GetMapping("/history/{userId}")
|
||||||
public ResponseEntity<APIResponse<List<Stream>>> getStreamHistory(@PathVariable Long userId) {
|
public ResponseEntity<APIResponse<List<Stream>>> getStreamHistory(@PathVariable Long userId) {
|
||||||
List<Stream> history = streamService.getStreamHistory(userId);
|
List<Stream> history = streamService.getStreamHistory(userId);
|
||||||
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream history retrieved", history));
|
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream history retrieved", history));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Reports whether the authenticated user is currently streaming.
|
|
||||||
*
|
|
||||||
* @return {@code 200 OK} wrapping a {@link StreamResponse} indicating the live status and, if live,
|
|
||||||
* the active stream's ID
|
|
||||||
*/
|
|
||||||
@GetMapping("/current")
|
@GetMapping("/current")
|
||||||
public ResponseEntity<APIResponse<StreamResponse>> getCurrentStream() {
|
public ResponseEntity<APIResponse<StreamResponse>> getCurrentStream() {
|
||||||
Optional<Stream> stream = streamService.getActiveStream();
|
Optional<Stream> stream = streamService.getActiveStream();
|
||||||
|
|||||||
@@ -27,12 +27,6 @@ public class UserController {
|
|||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the currently authenticated user's profile.
|
|
||||||
*
|
|
||||||
* @return {@code 200 OK} wrapping the authenticated {@link User}
|
|
||||||
* @throws NotAuthenticated if no valid session is present
|
|
||||||
*/
|
|
||||||
@GetMapping("/me")
|
@GetMapping("/me")
|
||||||
public ResponseEntity<APIResponse<User>> user() {
|
public ResponseEntity<APIResponse<User>> user() {
|
||||||
Optional<User> user = userService.getLoggedInUser();
|
Optional<User> user = userService.getLoggedInUser();
|
||||||
@@ -46,17 +40,6 @@ public class UserController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Authenticates a user from a Google ID token and issues a session JWT.
|
|
||||||
* <p>
|
|
||||||
* The JWT is set as an HttpOnly {@code token} cookie (scoped by request security) and also
|
|
||||||
* returned in the response body.
|
|
||||||
*
|
|
||||||
* @param token the payload carrying the Google ID token to verify
|
|
||||||
* @param request the incoming request, used to decide cookie {@code Secure}/{@code SameSite} flags
|
|
||||||
* @param response the response the session cookie is written to
|
|
||||||
* @return {@code 200 OK} wrapping the issued JWT as a {@link Token}
|
|
||||||
*/
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ResponseEntity<APIResponse<Token>> login(@RequestBody Token token,
|
public ResponseEntity<APIResponse<Token>> login(@RequestBody Token token,
|
||||||
HttpServletRequest request,
|
HttpServletRequest request,
|
||||||
@@ -78,13 +61,6 @@ public class UserController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Logs the user out by clearing the session cookie.
|
|
||||||
*
|
|
||||||
* @param response the response the expired cookie is written to
|
|
||||||
* @param request the incoming request, used to match the original cookie's {@code Secure}/{@code SameSite} flags
|
|
||||||
* @return {@code 200 OK} confirming logout
|
|
||||||
*/
|
|
||||||
@PostMapping("/logout")
|
@PostMapping("/logout")
|
||||||
public ResponseEntity<APIResponse<Void>> logout(HttpServletResponse response,
|
public ResponseEntity<APIResponse<Void>> logout(HttpServletResponse response,
|
||||||
HttpServletRequest request) {
|
HttpServletRequest request) {
|
||||||
|
|||||||
@@ -6,12 +6,17 @@ import com.ddf.vodsystem.dto.APIResponse;
|
|||||||
import com.ddf.vodsystem.entities.Vod;
|
import com.ddf.vodsystem.entities.Vod;
|
||||||
import com.ddf.vodsystem.services.VodService;
|
import com.ddf.vodsystem.services.VodService;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.http.*;
|
import org.springframework.core.io.support.ResourceRegion;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpRange;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.MediaTypeFactory;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/vods")
|
@RequestMapping("/api/v1/vods")
|
||||||
@@ -24,11 +29,6 @@ public class VodController {
|
|||||||
this.vodService = vodService;
|
this.vodService = vodService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Lists all VoDs belonging to the authenticated user.
|
|
||||||
*
|
|
||||||
* @return {@code 200 OK} wrapping the user's VoDs as {@link VodResponse} DTOs
|
|
||||||
*/
|
|
||||||
@GetMapping("")
|
@GetMapping("")
|
||||||
public ResponseEntity<APIResponse<List<VodResponse>>> getVods() {
|
public ResponseEntity<APIResponse<List<VodResponse>>> getVods() {
|
||||||
List<VodResponse> vods = vodService.getUserVods().stream()
|
List<VodResponse> vods = vodService.getUserVods().stream()
|
||||||
@@ -38,25 +38,12 @@ public class VodController {
|
|||||||
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vods retrieved successfully", vods));
|
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vods retrieved successfully", vods));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves a single VoD owned by the authenticated user.
|
|
||||||
*
|
|
||||||
* @param id the ID of the VoD to retrieve
|
|
||||||
* @return {@code 200 OK} wrapping the VoD as a {@link VodResponse} DTO
|
|
||||||
*/
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
public ResponseEntity<APIResponse<VodResponse>> getVodById(@PathVariable Long id) {
|
public ResponseEntity<APIResponse<VodResponse>> getVodById(@PathVariable Long id) {
|
||||||
VodResponse vod = convertToDTO(vodService.getVodById(id));
|
VodResponse vod = convertToDTO(vodService.getVodById(id));
|
||||||
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vod retrieved successfully", vod));
|
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vod retrieved successfully", vod));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies a partial update to a VoD's metadata; null fields are left unchanged.
|
|
||||||
*
|
|
||||||
* @param id the ID of the VoD to update
|
|
||||||
* @param updateFields the title and/or description to overwrite
|
|
||||||
* @return {@code 200 OK} wrapping the updated VoD as a {@link VodResponse} DTO
|
|
||||||
*/
|
|
||||||
@PatchMapping("/{id}")
|
@PatchMapping("/{id}")
|
||||||
public ResponseEntity<APIResponse<VodResponse>> updateVod(@PathVariable Long id,
|
public ResponseEntity<APIResponse<VodResponse>> updateVod(@PathVariable Long id,
|
||||||
@RequestBody VodUpdateRequest updateFields) {
|
@RequestBody VodUpdateRequest updateFields) {
|
||||||
@@ -64,12 +51,6 @@ public class VodController {
|
|||||||
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vod updated successfully", vod));
|
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vod updated successfully", vod));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a VoD and its associated files.
|
|
||||||
*
|
|
||||||
* @param id the ID of the VoD to delete
|
|
||||||
* @return {@code 200 OK} with a confirmation message
|
|
||||||
*/
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
public ResponseEntity<APIResponse<String>> deleteVod(@PathVariable Long id) {
|
public ResponseEntity<APIResponse<String>> deleteVod(@PathVariable Long id) {
|
||||||
vodService.deleteVod(id);
|
vodService.deleteVod(id);
|
||||||
@@ -77,39 +58,42 @@ public class VodController {
|
|||||||
"Vod with ID " + id + " has been deleted"));
|
"Vod with ID " + id + " has been deleted"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@GetMapping("/{id}/media")
|
||||||
* Streams the VoD's video file inline, supporting HTTP range requests.
|
public ResponseEntity<?> downloadVod(@PathVariable Long id, @RequestHeader HttpHeaders headers) throws IOException {
|
||||||
*
|
|
||||||
* @param id the ID of the VoD to download
|
|
||||||
* @return {@code 200 OK} with the video resource, or {@code 404 Not Found} if the file is missing
|
|
||||||
* @throws IOException if the resource's content length cannot be read
|
|
||||||
*/
|
|
||||||
@GetMapping(value = "/{id}/media", produces = MediaType.ALL_VALUE)
|
|
||||||
public ResponseEntity<Resource> downloadVod(@PathVariable Long id) throws IOException {
|
|
||||||
Resource resource = vodService.downloadVod(id);
|
Resource resource = vodService.downloadVod(id);
|
||||||
|
|
||||||
if (resource == null || !resource.exists()) {
|
if (resource == null || !resource.exists()) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<HttpRange> ranges = headers.getRange();
|
||||||
|
|
||||||
|
if (!ranges.isEmpty()) {
|
||||||
|
ResourceRegion region = createResourceRegion(resource, ranges.getFirst());
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
|
||||||
|
.header(HttpHeaders.ACCEPT_RANGES, "bytes")
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
|
||||||
|
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
|
||||||
|
.body(region);
|
||||||
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok()
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
.header(HttpHeaders.ACCEPT_RANGES, "bytes")
|
||||||
String.format(FILENAME_HEADER, resource.getFilename()))
|
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
|
||||||
.contentType(MediaTypeFactory.getMediaType(resource)
|
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
|
||||||
.orElse(MediaType.APPLICATION_OCTET_STREAM))
|
|
||||||
.contentLength(resource.contentLength())
|
|
||||||
.body(resource);
|
.body(resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private ResourceRegion createResourceRegion(Resource resource, HttpRange range) throws IOException {
|
||||||
* Streams the VoD's thumbnail image with a public cache policy.
|
long contentLength = resource.contentLength();
|
||||||
*
|
long start = range.getRangeStart(contentLength);
|
||||||
* @param id the ID of the VoD whose thumbnail to download
|
long end = range.getRangeEnd(contentLength);
|
||||||
* @return {@code 200 OK} with the thumbnail resource, or {@code 404 Not Found} if the file is missing
|
return new ResourceRegion(resource, start, end - start + 1);
|
||||||
* @throws IOException if the resource's last-modified time cannot be read
|
}
|
||||||
*/
|
|
||||||
@GetMapping("/{id}/thumbnail")
|
@GetMapping("/{id}/thumbnail")
|
||||||
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) throws IOException {
|
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) {
|
||||||
Resource resource = vodService.downloadThumbnail(id);
|
Resource resource = vodService.downloadThumbnail(id);
|
||||||
|
|
||||||
if (resource == null || !resource.exists()) {
|
if (resource == null || !resource.exists()) {
|
||||||
@@ -117,8 +101,6 @@ public class VodController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok()
|
||||||
.cacheControl(CacheControl.maxAge(2, TimeUnit.DAYS).cachePublic())
|
|
||||||
.lastModified(resource.lastModified())
|
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
|
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
|
||||||
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
|
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
|
||||||
.body(resource);
|
.body(resource);
|
||||||
|
|||||||
@@ -10,11 +10,6 @@ public class CommandOutput {
|
|||||||
private List<String> output = new ArrayList<>();
|
private List<String> output = new ArrayList<>();
|
||||||
private int exitCode;
|
private int exitCode;
|
||||||
|
|
||||||
/**
|
|
||||||
* Appends a line to the captured command output.
|
|
||||||
*
|
|
||||||
* @param line the output line to append
|
|
||||||
*/
|
|
||||||
public void addLine(String line) {
|
public void addLine(String line) {
|
||||||
output.add(line);
|
output.add(line);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,30 +4,14 @@ public class ProgressTracker {
|
|||||||
private float progress = 0.0f;
|
private float progress = 0.0f;
|
||||||
private boolean isComplete = false;
|
private boolean isComplete = false;
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the current progress.
|
|
||||||
*
|
|
||||||
* @return the progress as a fraction in {@code [0, 1]}
|
|
||||||
*/
|
|
||||||
public synchronized float getProgress() {
|
public synchronized float getProgress() {
|
||||||
return progress;
|
return progress;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns whether the tracked task has been marked complete.
|
|
||||||
*
|
|
||||||
* @return {@code true} if complete
|
|
||||||
*/
|
|
||||||
public synchronized boolean isComplete() {
|
public synchronized boolean isComplete() {
|
||||||
return isComplete;
|
return isComplete;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the current progress.
|
|
||||||
*
|
|
||||||
* @param newProgress the progress as a fraction in {@code [0, 1]}
|
|
||||||
* @throws IllegalArgumentException if {@code newProgress} is outside {@code [0, 1]}
|
|
||||||
*/
|
|
||||||
public synchronized void setProgress(float newProgress) {
|
public synchronized void setProgress(float newProgress) {
|
||||||
if (newProgress < 0 || newProgress > 1) {
|
if (newProgress < 0 || newProgress > 1) {
|
||||||
throw new IllegalArgumentException("Progress must be between 0 and 1");
|
throw new IllegalArgumentException("Progress must be between 0 and 1");
|
||||||
@@ -35,16 +19,10 @@ public class ProgressTracker {
|
|||||||
this.progress = newProgress;
|
this.progress = newProgress;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Marks the tracked task as complete.
|
|
||||||
*/
|
|
||||||
public synchronized void markComplete() {
|
public synchronized void markComplete() {
|
||||||
this.isComplete = true;
|
this.isComplete = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resets progress to zero and clears the complete flag.
|
|
||||||
*/
|
|
||||||
public synchronized void reset() {
|
public synchronized void reset() {
|
||||||
this.progress = 0.0f;
|
this.progress = 0.0f;
|
||||||
this.isComplete = false;
|
this.isComplete = false;
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
package com.ddf.vodsystem.exceptions;
|
|
||||||
|
|
||||||
public class ClipMediaUnavailable extends RuntimeException {
|
|
||||||
private final Long clipId;
|
|
||||||
|
|
||||||
public ClipMediaUnavailable(Long clipId, String message) {
|
|
||||||
this(clipId, message, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public ClipMediaUnavailable(Long clipId, String message, Throwable cause) {
|
|
||||||
super(message, cause);
|
|
||||||
this.clipId = clipId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the ID of the clip whose media was unavailable.
|
|
||||||
*
|
|
||||||
* @return the affected clip's ID
|
|
||||||
*/
|
|
||||||
public Long getClipId() {
|
|
||||||
return clipId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package com.ddf.vodsystem.exceptions;
|
|
||||||
|
|
||||||
public class VodMediaUnavailable extends RuntimeException {
|
|
||||||
private final Long vodId;
|
|
||||||
|
|
||||||
public VodMediaUnavailable(Long vodId, String message) {
|
|
||||||
this(vodId, message, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public VodMediaUnavailable(Long vodId, String message, Throwable cause) {
|
|
||||||
super(message, cause);
|
|
||||||
this.vodId = vodId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the ID of the VoD whose media was unavailable.
|
|
||||||
*
|
|
||||||
* @return the affected VoD's ID
|
|
||||||
*/
|
|
||||||
public Long getVodId() {
|
|
||||||
return vodId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,12 +10,6 @@ import java.util.List;
|
|||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface ClipRepository extends JpaRepository<Clip, Long> {
|
public interface ClipRepository extends JpaRepository<Clip, Long> {
|
||||||
/**
|
|
||||||
* Finds all clips owned by the given user.
|
|
||||||
*
|
|
||||||
* @param user the owning user
|
|
||||||
* @return the user's clips
|
|
||||||
*/
|
|
||||||
@Query("SELECT c FROM Clip c WHERE c.user = ?1")
|
@Query("SELECT c FROM Clip c WHERE c.user = ?1")
|
||||||
List<Clip> findByUser(User user);
|
List<Clip> findByUser(User user);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,21 +11,9 @@ import java.util.List;
|
|||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface MarkerRepository extends JpaRepository<Marker, Long> {
|
public interface MarkerRepository extends JpaRepository<Marker, Long> {
|
||||||
/**
|
|
||||||
* Finds all markers owned by the given user.
|
|
||||||
*
|
|
||||||
* @param user the owning user
|
|
||||||
* @return the user's markers
|
|
||||||
*/
|
|
||||||
@Query("SELECT m FROM Marker m WHERE m.user = ?1")
|
@Query("SELECT m FROM Marker m WHERE m.user = ?1")
|
||||||
List<Marker> findByUser(User user);
|
List<Marker> findByUser(User user);
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds all markers timestamped at or before the given instant.
|
|
||||||
*
|
|
||||||
* @param time the cutoff instant (inclusive)
|
|
||||||
* @return markers at or before {@code time}
|
|
||||||
*/
|
|
||||||
@Query("SELECT m FROM Marker m WHERE m.timestamp <= ?1")
|
@Query("SELECT m FROM Marker m WHERE m.timestamp <= ?1")
|
||||||
List<Marker> findAllBefore(Instant time);
|
List<Marker> findAllBefore(Instant time);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,27 +11,7 @@ import java.util.Optional;
|
|||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface StreamRepository extends JpaRepository<Stream, Long> {
|
public interface StreamRepository extends JpaRepository<Stream, Long> {
|
||||||
/**
|
|
||||||
* Finds the user's active (not-yet-ended) stream, if any.
|
|
||||||
*
|
|
||||||
* @param user the owning user
|
|
||||||
* @return the active stream, or empty if none
|
|
||||||
*/
|
|
||||||
Optional<Stream> findByUserAndEndDateIsNull(User user);
|
Optional<Stream> findByUserAndEndDateIsNull(User user);
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds all streams belonging to the given user.
|
|
||||||
*
|
|
||||||
* @param user the owning user
|
|
||||||
* @return the user's streams
|
|
||||||
*/
|
|
||||||
List<Stream> findByUser(User user);
|
List<Stream> findByUser(User user);
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds active streams whose last heartbeat predates the given cutoff, i.e. stale streams.
|
|
||||||
*
|
|
||||||
* @param cutoff the last-seen cutoff; streams last seen before this are returned
|
|
||||||
* @return the stale active streams
|
|
||||||
*/
|
|
||||||
List<Stream> findByEndDateIsNullAndLastSeenBefore(Instant cutoff);
|
List<Stream> findByEndDateIsNullAndLastSeenBefore(Instant cutoff);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,21 +9,9 @@ import java.util.Optional;
|
|||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface UserRepository extends JpaRepository<User, Long> {
|
public interface UserRepository extends JpaRepository<User, Long> {
|
||||||
/**
|
|
||||||
* Finds a user by their Google account ID.
|
|
||||||
*
|
|
||||||
* @param googleId the Google subject identifier
|
|
||||||
* @return the matching user, or empty if none
|
|
||||||
*/
|
|
||||||
@Query("SELECT u FROM User u WHERE u.googleId = ?1")
|
@Query("SELECT u FROM User u WHERE u.googleId = ?1")
|
||||||
Optional<User> findByGoogleId(String googleId);
|
Optional<User> findByGoogleId(String googleId);
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds a user by their stream key.
|
|
||||||
*
|
|
||||||
* @param streamKey the stream key to match
|
|
||||||
* @return the matching user, or empty if none
|
|
||||||
*/
|
|
||||||
@Query("SELECT u FROM User u WHERE u.streamKey = ?1")
|
@Query("SELECT u FROM User u WHERE u.streamKey = ?1")
|
||||||
Optional<User> findByStreamKey(String streamKey);
|
Optional<User> findByStreamKey(String streamKey);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,6 @@ import java.util.List;
|
|||||||
@Repository
|
@Repository
|
||||||
public interface VodRepository extends JpaRepository<Vod, Long>
|
public interface VodRepository extends JpaRepository<Vod, Long>
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* Finds all VoDs owned by the given user.
|
|
||||||
*
|
|
||||||
* @param user the owning user
|
|
||||||
* @return the user's VoDs
|
|
||||||
*/
|
|
||||||
@Query("SELECT v FROM Vod v WHERE v.user = ?1")
|
@Query("SELECT v FROM Vod v WHERE v.user = ?1")
|
||||||
List<Vod> findByUser(User user);
|
List<Vod> findByUser(User user);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,12 +26,6 @@ public class JwtService {
|
|||||||
this.algorithm = Algorithm.HMAC256(jwtSecretKey);
|
this.algorithm = Algorithm.HMAC256(jwtSecretKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a signed JWT carrying the given user ID, valid for the configured expiry.
|
|
||||||
*
|
|
||||||
* @param userId the user ID to embed as a claim
|
|
||||||
* @return the signed JWT string
|
|
||||||
*/
|
|
||||||
public String generateToken(Long userId) {
|
public String generateToken(Long userId) {
|
||||||
return JWT.create()
|
return JWT.create()
|
||||||
.withClaim(USER_ID_CLAIM, userId)
|
.withClaim(USER_ID_CLAIM, userId)
|
||||||
@@ -40,12 +34,6 @@ public class JwtService {
|
|||||||
.sign(algorithm);
|
.sign(algorithm);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies a JWT's signature, issuer, and expiry, returning the embedded user ID.
|
|
||||||
*
|
|
||||||
* @param token the JWT to validate
|
|
||||||
* @return the user ID claim if the token is valid and unexpired, otherwise {@code null}
|
|
||||||
*/
|
|
||||||
public Long validateTokenAndGetUserId(String token) {
|
public Long validateTokenAndGetUserId(String token) {
|
||||||
try {
|
try {
|
||||||
JWTVerifier verifier = JWT.require(algorithm)
|
JWTVerifier verifier = JWT.require(algorithm)
|
||||||
|
|||||||
@@ -156,10 +156,6 @@ public class ClipService {
|
|||||||
throw new ClipNotFound("Clip file not found");
|
throw new ClipNotFound("Clip file not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Files.isReadable(file)) {
|
|
||||||
throw new ClipMediaUnavailable(id, "Clip file is not readable");
|
|
||||||
}
|
|
||||||
|
|
||||||
return new FileSystemResource(file);
|
return new FileSystemResource(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,132 +40,61 @@ public class DirectoryService {
|
|||||||
private static final String CLIPS_DIR_NAME = "clips";
|
private static final String CLIPS_DIR_NAME = "clips";
|
||||||
private static final String THUMBNAIL_DIR_NAME = "thumbnails";
|
private static final String THUMBNAIL_DIR_NAME = "thumbnails";
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the directory for temporary upload inputs.
|
|
||||||
*
|
|
||||||
* @return the configured temporary input directory path
|
|
||||||
*/
|
|
||||||
public Path getTempInputDir() {
|
public Path getTempInputDir() {
|
||||||
return Path.of(tempInputsDir);
|
return Path.of(tempInputsDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the directory for temporary processing outputs.
|
|
||||||
*
|
|
||||||
* @return the configured temporary output directory path
|
|
||||||
*/
|
|
||||||
public Path getTempOutputDir() {
|
public Path getTempOutputDir() {
|
||||||
return Path.of(tempOutputsDir);
|
return Path.of(tempOutputsDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the given user's root media directory, creating it if necessary.
|
|
||||||
*
|
|
||||||
* @param userId the ID of the user
|
|
||||||
* @return the user's media directory path
|
|
||||||
* @throws IOException if the directory cannot be created
|
|
||||||
*/
|
|
||||||
public Path getUserDir(Long userId) throws IOException {
|
public Path getUserDir(Long userId) throws IOException {
|
||||||
Path userDir = Path.of(usersDir + File.separator + userId);
|
Path userDir = Path.of(usersDir + File.separator + userId);
|
||||||
Files.createDirectories(userDir);
|
Files.createDirectories(userDir);
|
||||||
return userDir;
|
return userDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the given user's VoDs directory, creating it if necessary.
|
|
||||||
*
|
|
||||||
* @param userId the ID of the user
|
|
||||||
* @return the user's VoDs directory path
|
|
||||||
* @throws IOException if the directory cannot be created
|
|
||||||
*/
|
|
||||||
public Path getVodsDir(Long userId) throws IOException {
|
public Path getVodsDir(Long userId) throws IOException {
|
||||||
Path vodDir = getUserDir(userId).resolve(VODS_DIR_NAME);
|
Path vodDir = getUserDir(userId).resolve(VODS_DIR_NAME);
|
||||||
Files.createDirectories(vodDir);
|
Files.createDirectories(vodDir);
|
||||||
return vodDir;
|
return vodDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the given user's clips directory, creating it if necessary.
|
|
||||||
*
|
|
||||||
* @param userId the ID of the user
|
|
||||||
* @return the user's clips directory path
|
|
||||||
* @throws IOException if the directory cannot be created
|
|
||||||
*/
|
|
||||||
public Path getClipsDir(Long userId) throws IOException {
|
public Path getClipsDir(Long userId) throws IOException {
|
||||||
Path clipsDir = getUserDir(userId).resolve(CLIPS_DIR_NAME);
|
Path clipsDir = getUserDir(userId).resolve(CLIPS_DIR_NAME);
|
||||||
Files.createDirectories(clipsDir);
|
Files.createDirectories(clipsDir);
|
||||||
return clipsDir;
|
return clipsDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the given user's thumbnails directory, creating it if necessary.
|
|
||||||
*
|
|
||||||
* @param userId the ID of the user
|
|
||||||
* @return the user's thumbnails directory path
|
|
||||||
* @throws IOException if the directory cannot be created
|
|
||||||
*/
|
|
||||||
public Path getThumbnailsDir(Long userId) throws IOException {
|
public Path getThumbnailsDir(Long userId) throws IOException {
|
||||||
Path thumbnailsDir = getUserDir(userId).resolve(THUMBNAIL_DIR_NAME);
|
Path thumbnailsDir = getUserDir(userId).resolve(THUMBNAIL_DIR_NAME);
|
||||||
Files.createDirectories(thumbnailsDir);
|
Files.createDirectories(thumbnailsDir);
|
||||||
return thumbnailsDir;
|
return thumbnailsDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the directory holding a stream's recorded HLS segments, creating it if necessary.
|
|
||||||
*
|
|
||||||
* @param streamKey the stream key naming the segment directory
|
|
||||||
* @return the stream's segment directory path
|
|
||||||
* @throws IOException if the directory cannot be created
|
|
||||||
*/
|
|
||||||
public Path getStreamDir(String streamKey) throws IOException {
|
public Path getStreamDir(String streamKey) throws IOException {
|
||||||
Path streamFolder = Path.of(streamsDir, streamKey);
|
Path streamFolder = Path.of(streamsDir, streamKey);
|
||||||
Files.createDirectories(streamFolder);
|
Files.createDirectories(streamFolder);
|
||||||
return streamFolder;
|
return streamFolder;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts an absolute path into one relative to the users media root, for storage in the database.
|
|
||||||
*
|
|
||||||
* @param path the absolute path to relativise
|
|
||||||
* @return the path relative to the users media directory
|
|
||||||
*/
|
|
||||||
public Path relativisePath(Path path) {
|
public Path relativisePath(Path path) {
|
||||||
Path media = Path.of(usersDir).toAbsolutePath();
|
Path media = Path.of(usersDir).toAbsolutePath();
|
||||||
Path newPath = path.toAbsolutePath();
|
Path newPath = path.toAbsolutePath();
|
||||||
return media.relativize(newPath);
|
return media.relativize(newPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves a stored relative path back into an absolute path under the users media root.
|
|
||||||
*
|
|
||||||
* @param path the relative path, as stored in the database
|
|
||||||
* @return the absolute path on disk
|
|
||||||
*/
|
|
||||||
public Path resolvePath(String path) {
|
public Path resolvePath(String path) {
|
||||||
Path media = Path.of(usersDir).toAbsolutePath();
|
Path media = Path.of(usersDir).toAbsolutePath();
|
||||||
Path newPath = Path.of(path);
|
Path newPath = Path.of(path);
|
||||||
return media.resolve(newPath);
|
return media.resolve(newPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Writes an uploaded file to the given path, creating parent directories and overwriting any existing file.
|
|
||||||
*
|
|
||||||
* @param path the destination path
|
|
||||||
* @param multipartFile the uploaded file to save
|
|
||||||
* @throws IOException if writing the file fails
|
|
||||||
*/
|
|
||||||
public void saveMultipartFile(Path path, MultipartFile multipartFile) throws IOException {
|
public void saveMultipartFile(Path path, MultipartFile multipartFile) throws IOException {
|
||||||
Files.createDirectories(path.getParent());
|
Files.createDirectories(path.getParent());
|
||||||
Files.copy(multipartFile.getInputStream(), path, StandardCopyOption.REPLACE_EXISTING);
|
Files.copy(multipartFile.getInputStream(), path, StandardCopyOption.REPLACE_EXISTING);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Copies a file, creating parent directories and overwriting any existing target.
|
|
||||||
*
|
|
||||||
* @param source the file to copy from
|
|
||||||
* @param target the destination path
|
|
||||||
* @throws IOException if the copy fails
|
|
||||||
*/
|
|
||||||
public void copyFile(Path source, Path target) throws IOException {
|
public void copyFile(Path source, Path target) throws IOException {
|
||||||
Files.createDirectories(target.getParent());
|
Files.createDirectories(target.getParent());
|
||||||
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
|
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
|
||||||
@@ -217,12 +146,6 @@ public class DirectoryService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates the temporary and users media directories on startup and verifies they are usable.
|
|
||||||
*
|
|
||||||
* @throws IOException if a directory cannot be created
|
|
||||||
* @throws IllegalStateException if any required directory is not a readable, writable directory
|
|
||||||
*/
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void createDirectoriesIfNotExist() throws IOException {
|
public void createDirectoriesIfNotExist() throws IOException {
|
||||||
Files.createDirectories(Path.of(tempInputsDir));
|
Files.createDirectories(Path.of(tempInputsDir));
|
||||||
@@ -232,13 +155,6 @@ public class DirectoryService {
|
|||||||
checkDirPermissions();
|
checkDirPermissions();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Scheduled cleanup that deletes stale files from the temporary input and output directories.
|
|
||||||
* <p>
|
|
||||||
* Runs every 30 minutes; files older than 3 hours are removed.
|
|
||||||
*
|
|
||||||
* @throws IOException if listing a temporary directory fails
|
|
||||||
*/
|
|
||||||
@Scheduled(fixedRate = TEMP_DIR_CLEANUP_RATE)
|
@Scheduled(fixedRate = TEMP_DIR_CLEANUP_RATE)
|
||||||
public void cleanTempDirectories() throws IOException {
|
public void cleanTempDirectories() throws IOException {
|
||||||
cleanUpDirectory(tempInputsDir);
|
cleanUpDirectory(tempInputsDir);
|
||||||
|
|||||||
@@ -32,14 +32,6 @@ public class MarkerService {
|
|||||||
this.markerRepository = markerRepository;
|
this.markerRepository = markerRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the marker with the given ID, verifying ownership by the current user.
|
|
||||||
*
|
|
||||||
* @param id the ID of the marker to retrieve
|
|
||||||
* @return the matching {@link Marker}
|
|
||||||
* @throws MarkerNotFound if no marker with {@code id} exists
|
|
||||||
* @throws NotAuthenticated if the current user does not own the marker
|
|
||||||
*/
|
|
||||||
public Marker getMarkerById(Long id) {
|
public Marker getMarkerById(Long id) {
|
||||||
Optional<Marker> marker = markerRepository.findById(id);
|
Optional<Marker> marker = markerRepository.findById(id);
|
||||||
|
|
||||||
@@ -55,12 +47,6 @@ public class MarkerService {
|
|||||||
return marker.get();
|
return marker.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns all markers belonging to the currently authenticated user.
|
|
||||||
*
|
|
||||||
* @return list of the current user's markers
|
|
||||||
* @throws NotAuthenticated if no user session is present
|
|
||||||
*/
|
|
||||||
public List<Marker> getUserMarkers() {
|
public List<Marker> getUserMarkers() {
|
||||||
Optional<User> user = userService.getLoggedInUser();
|
Optional<User> user = userService.getLoggedInUser();
|
||||||
|
|
||||||
@@ -71,13 +57,6 @@ public class MarkerService {
|
|||||||
return markerRepository.findByUser(user.get());
|
return markerRepository.findByUser(user.get());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a marker timestamped now on the current user's active stream.
|
|
||||||
*
|
|
||||||
* @param message the marker's descriptive message
|
|
||||||
* @return the persisted {@link Marker}
|
|
||||||
* @throws NotStreaming if the user has no active stream to mark
|
|
||||||
*/
|
|
||||||
public Marker create(String message) {
|
public Marker create(String message) {
|
||||||
Optional<Stream> stream = streamService.getActiveStream();
|
Optional<Stream> stream = streamService.getActiveStream();
|
||||||
|
|
||||||
@@ -93,23 +72,11 @@ public class MarkerService {
|
|||||||
return markerRepository.saveAndFlush(marker);
|
return markerRepository.saveAndFlush(marker);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a marker owned by the current user.
|
|
||||||
*
|
|
||||||
* @param id the ID of the marker to delete
|
|
||||||
* @throws MarkerNotFound if no marker with {@code id} exists
|
|
||||||
* @throws NotAuthenticated if the current user does not own the marker
|
|
||||||
*/
|
|
||||||
public void deleteMarker(Long id) {
|
public void deleteMarker(Long id) {
|
||||||
Marker marker = getMarkerById(id);
|
Marker marker = getMarkerById(id);
|
||||||
markerRepository.delete(marker);
|
markerRepository.delete(marker);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Scheduled cleanup that deletes markers older than the configured maximum stream length.
|
|
||||||
* <p>
|
|
||||||
* Runs periodically (every 6 minutes) to purge markers left behind by streams that have ended.
|
|
||||||
*/
|
|
||||||
@Scheduled(fixedDelay = 360_000)
|
@Scheduled(fixedDelay = 360_000)
|
||||||
@Transactional
|
@Transactional
|
||||||
public void deleteOldMarkers() {
|
public void deleteOldMarkers() {
|
||||||
|
|||||||
@@ -133,10 +133,11 @@ public class MediaService {
|
|||||||
.orElseThrow(() -> new NotAuthenticated("User is not authenticated"));
|
.orElseThrow(() -> new NotAuthenticated("User is not authenticated"));
|
||||||
|
|
||||||
Job job = jobRegistryService.generateJob();
|
Job job = jobRegistryService.generateJob();
|
||||||
StreamActionsService.SegmentSelection ctx = resolveSegments(user, startTime, endTime);
|
SegmentContext ctx = resolveSegments(user, startTime, endTime);
|
||||||
|
float duration = (endTime.toEpochMilli() - startTime.toEpochMilli()) / 1000f;
|
||||||
Path outputFile = directoryService.getVodsDir(user.getId()).resolve(UUID.randomUUID() + ".mp4");
|
Path outputFile = directoryService.getVodsDir(user.getId()).resolve(UUID.randomUUID() + ".mp4");
|
||||||
|
|
||||||
return dispatchSaveJob(job, ctx, outputFile, () ->
|
return dispatchSaveJob(job, ctx, duration, outputFile, () ->
|
||||||
vodService.persist(
|
vodService.persist(
|
||||||
title == null ? Instant.now().toString() : title,
|
title == null ? Instant.now().toString() : title,
|
||||||
description == null ? "" : description,
|
description == null ? "" : description,
|
||||||
@@ -210,10 +211,10 @@ public class MediaService {
|
|||||||
.orElseThrow(() -> new NotAuthenticated("User is not authenticated"));
|
.orElseThrow(() -> new NotAuthenticated("User is not authenticated"));
|
||||||
|
|
||||||
Job job = jobRegistryService.generateJob();
|
Job job = jobRegistryService.generateJob();
|
||||||
StreamActionsService.SegmentSelection ctx = resolveSegments(user, startTime, endTime);
|
SegmentContext ctx = resolveSegments(user, startTime, endTime);
|
||||||
Path outputFile = directoryService.getTempOutputDir().resolve(UUID.randomUUID() + ".mp4");
|
Path outputFile = directoryService.getTempOutputDir().resolve(UUID.randomUUID() + ".mp4");
|
||||||
|
|
||||||
return dispatchSaveJob(job, ctx, outputFile, () ->
|
return dispatchSaveJob(job, ctx, duration, outputFile, () ->
|
||||||
clipService.persistClip(
|
clipService.persistClip(
|
||||||
title == null ? Instant.now().toString() : title,
|
title == null ? Instant.now().toString() : title,
|
||||||
description == null ? "" : description,
|
description == null ? "" : description,
|
||||||
@@ -224,14 +225,22 @@ public class MediaService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private StreamActionsService.SegmentSelection resolveSegments(User user, Instant startTime, Instant endTime) throws IOException {
|
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());
|
Path streamDir = directoryService.getStreamDir(user.getStreamKey());
|
||||||
return streamActionsService.selectSegments(streamDir, startTime, endTime);
|
List<Path> segments = streamActionsService.getSegmentsInRange(streamDir, startTime, endTime);
|
||||||
|
if (segments.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("No stream segments found in the given time range");
|
||||||
|
}
|
||||||
|
long firstMs = streamActionsService.parseTimestampMs(segments.getFirst());
|
||||||
|
float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstMs) / 1000f);
|
||||||
|
return new SegmentContext(segments, trimOffset);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Job dispatchSaveJob(Job job, StreamActionsService.SegmentSelection ctx, Path outputFile, Runnable onSuccess) {
|
private Job dispatchSaveJob(Job job, SegmentContext ctx, float duration, Path outputFile, Runnable onSuccess) {
|
||||||
job.setState(JobState.PROCESSING);
|
job.setState(JobState.PROCESSING);
|
||||||
streamActionsService.saveSection(ctx, outputFile, job.getProgressTracker())
|
streamActionsService.saveSection(ctx.segments(), ctx.trimOffset(), duration, outputFile, job.getProgressTracker())
|
||||||
.thenRun(() -> {
|
.thenRun(() -> {
|
||||||
job.setDownload(outputFile);
|
job.setDownload(outputFile);
|
||||||
job.setState(JobState.SUCCEEDED);
|
job.setState(JobState.SUCCEEDED);
|
||||||
|
|||||||
@@ -30,14 +30,6 @@ public class StreamService {
|
|||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts a new stream for the user identified by the given stream key.
|
|
||||||
*
|
|
||||||
* @param streamKey the stream key resolving to the broadcasting user
|
|
||||||
* @return the newly persisted {@link Stream}
|
|
||||||
* @throws KeyNotFound if no user owns {@code streamKey}
|
|
||||||
* @throws AlreadyStreaming if the user already has an active stream
|
|
||||||
*/
|
|
||||||
public Stream startStream(String streamKey) {
|
public Stream startStream(String streamKey) {
|
||||||
User user = resolveUser(streamKey);
|
User user = resolveUser(streamKey);
|
||||||
|
|
||||||
@@ -53,13 +45,6 @@ public class StreamService {
|
|||||||
return streamRepository.saveAndFlush(stream);
|
return streamRepository.saveAndFlush(stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Ends the active stream for the user identified by the given stream key.
|
|
||||||
*
|
|
||||||
* @param streamKey the stream key resolving to the broadcasting user
|
|
||||||
* @throws KeyNotFound if no user owns {@code streamKey}
|
|
||||||
* @throws IllegalStateException if the user has no active stream
|
|
||||||
*/
|
|
||||||
public void endStream(String streamKey) {
|
public void endStream(String streamKey) {
|
||||||
User user = resolveUser(streamKey);
|
User user = resolveUser(streamKey);
|
||||||
|
|
||||||
@@ -71,13 +56,6 @@ public class StreamService {
|
|||||||
streamRepository.saveAndFlush(stream);
|
streamRepository.saveAndFlush(stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Records a liveness heartbeat on the active stream, refreshing its last-seen time.
|
|
||||||
*
|
|
||||||
* @param streamKey the stream key resolving to the broadcasting user
|
|
||||||
* @throws KeyNotFound if no user owns {@code streamKey}
|
|
||||||
* @throws IllegalStateException if the user has no active stream
|
|
||||||
*/
|
|
||||||
public void heartbeatStream(String streamKey) {
|
public void heartbeatStream(String streamKey) {
|
||||||
User user = resolveUser(streamKey);
|
User user = resolveUser(streamKey);
|
||||||
|
|
||||||
@@ -89,12 +67,6 @@ public class StreamService {
|
|||||||
streamRepository.saveAndFlush(stream);
|
streamRepository.saveAndFlush(stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the current user's active (not-yet-ended) stream, if any.
|
|
||||||
*
|
|
||||||
* @return the active {@link Stream}, or empty if the user is not currently streaming
|
|
||||||
* @throws NotAuthenticated if no user session is present
|
|
||||||
*/
|
|
||||||
public Optional<Stream> getActiveStream() {
|
public Optional<Stream> getActiveStream() {
|
||||||
Optional<User> user = userService.getLoggedInUser();
|
Optional<User> user = userService.getLoggedInUser();
|
||||||
|
|
||||||
@@ -105,14 +77,6 @@ public class StreamService {
|
|||||||
return streamRepository.findByUserAndEndDateIsNull(user.get());
|
return streamRepository.findByUserAndEndDateIsNull(user.get());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the full stream history for the given user.
|
|
||||||
*
|
|
||||||
* @param userId the ID of the user whose history to retrieve
|
|
||||||
* @return the user's streams, past and present
|
|
||||||
* @throws IllegalArgumentException if no user with {@code userId} exists
|
|
||||||
* @throws NotAuthenticated if no user is authenticated, or the caller is not that user
|
|
||||||
*/
|
|
||||||
public List<Stream> getStreamHistory(Long userId) {
|
public List<Stream> getStreamHistory(Long userId) {
|
||||||
User streamUser = userService.getUserById(userId)
|
User streamUser = userService.getUserById(userId)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));
|
.orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));
|
||||||
@@ -130,12 +94,6 @@ public class StreamService {
|
|||||||
return streamRepository.findByUser(streamUser);
|
return streamRepository.findByUser(streamUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Scheduled sweep that ends streams which have missed heartbeats past the timeout.
|
|
||||||
* <p>
|
|
||||||
* Runs every 30 seconds; any active stream whose last heartbeat is older than
|
|
||||||
* {@value #HEARTBEAT_TIMEOUT_SECONDS} seconds is marked ended.
|
|
||||||
*/
|
|
||||||
@Scheduled(fixedDelay = 30_000)
|
@Scheduled(fixedDelay = 30_000)
|
||||||
@Transactional
|
@Transactional
|
||||||
public void endStaleStreams() {
|
public void endStaleStreams() {
|
||||||
|
|||||||
@@ -44,21 +44,10 @@ public class UserService {
|
|||||||
this.jwtService = jwtService;
|
this.jwtService = jwtService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Looks up a user by their database ID.
|
|
||||||
*
|
|
||||||
* @param userId the ID of the user to find
|
|
||||||
* @return the matching {@link User}, or empty if none exists
|
|
||||||
*/
|
|
||||||
public Optional<User> getUserById(Long userId) {
|
public Optional<User> getUserById(Long userId) {
|
||||||
return userRepository.findById(userId);
|
return userRepository.findById(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the user backing the current Spring Security authentication, if authenticated.
|
|
||||||
*
|
|
||||||
* @return the authenticated {@link User}, or empty if no user is authenticated
|
|
||||||
*/
|
|
||||||
public Optional<User> getLoggedInUser() {
|
public Optional<User> getLoggedInUser() {
|
||||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
if (auth != null && auth.isAuthenticated() && auth.getPrincipal() instanceof User user) {
|
if (auth != null && auth.isAuthenticated() && auth.getPrincipal() instanceof User user) {
|
||||||
@@ -67,13 +56,6 @@ public class UserService {
|
|||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies a Google ID token, creating or updating the corresponding user, and issues a session JWT.
|
|
||||||
*
|
|
||||||
* @param idToken the Google ID token to verify
|
|
||||||
* @return a signed JWT for the authenticated user
|
|
||||||
* @throws NotAuthenticated if the token is invalid or its subject is missing
|
|
||||||
*/
|
|
||||||
public String login(String idToken) {
|
public String login(String idToken) {
|
||||||
GoogleIdToken googleIdToken = getGoogleIdToken(idToken);
|
GoogleIdToken googleIdToken = getGoogleIdToken(idToken);
|
||||||
String googleId = googleIdToken.getPayload().getSubject();
|
String googleId = googleIdToken.getPayload().getSubject();
|
||||||
@@ -88,12 +70,6 @@ public class UserService {
|
|||||||
return jwtService.generateToken(user.getId());
|
return jwtService.generateToken(user.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Looks up a user by their stream key.
|
|
||||||
*
|
|
||||||
* @param streamKey the stream key to match
|
|
||||||
* @return the matching {@link User}, or empty if none exists
|
|
||||||
*/
|
|
||||||
public Optional<User> getUserByStreamKey(String streamKey) {
|
public Optional<User> getUserByStreamKey(String streamKey) {
|
||||||
return userRepository.findByStreamKey(streamKey);
|
return userRepository.findByStreamKey(streamKey);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,10 +139,6 @@ public class VodService {
|
|||||||
throw new VodNotFound("Vod file not found for ID: " + id);
|
throw new VodNotFound("Vod file not found for ID: " + id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Files.isReadable(file)) {
|
|
||||||
throw new VodMediaUnavailable(id, "Vod file is not readable");
|
|
||||||
}
|
|
||||||
|
|
||||||
return new FileSystemResource(file);
|
return new FileSystemResource(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,15 +16,6 @@ import java.util.regex.Pattern;
|
|||||||
public class CommandRunner {
|
public class CommandRunner {
|
||||||
private static final Pattern timePattern = Pattern.compile("out_time_ms=(\\d+)");
|
private static final Pattern timePattern = Pattern.compile("out_time_ms=(\\d+)");
|
||||||
|
|
||||||
/**
|
|
||||||
* Runs an external command, capturing its merged stdout/stderr and streaming each line to a callback.
|
|
||||||
*
|
|
||||||
* @param command the command and its arguments
|
|
||||||
* @param onOutput invoked for each output line as it is read, or {@code null} to skip streaming
|
|
||||||
* @return the captured output and exit code
|
|
||||||
* @throws IOException if the process fails to start or exits with a non-zero code
|
|
||||||
* @throws InterruptedException if the current thread is interrupted while awaiting the process
|
|
||||||
*/
|
|
||||||
public CommandOutput run(List<String> command, Consumer<String> onOutput) throws IOException, InterruptedException {
|
public CommandOutput run(List<String> command, Consumer<String> onOutput) throws IOException, InterruptedException {
|
||||||
ProcessBuilder processBuilder = new ProcessBuilder(command);
|
ProcessBuilder processBuilder = new ProcessBuilder(command);
|
||||||
processBuilder.redirectErrorStream(true);
|
processBuilder.redirectErrorStream(true);
|
||||||
@@ -52,27 +43,10 @@ public class CommandRunner {
|
|||||||
return commandOutput;
|
return commandOutput;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Runs an external command, capturing its output without streaming.
|
|
||||||
*
|
|
||||||
* @param command the command and its arguments
|
|
||||||
* @return the captured output and exit code
|
|
||||||
* @throws IOException if the process fails to start or exits with a non-zero code
|
|
||||||
* @throws InterruptedException if the current thread is interrupted while awaiting the process
|
|
||||||
*/
|
|
||||||
public CommandOutput run(List<String> command) throws IOException, InterruptedException {
|
public CommandOutput run(List<String> command) throws IOException, InterruptedException {
|
||||||
return run(command, null);
|
return run(command, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses an ffmpeg progress line and updates the tracker with the fraction completed.
|
|
||||||
* <p>
|
|
||||||
* Lines without an {@code out_time_ms} field are ignored.
|
|
||||||
*
|
|
||||||
* @param line a single line of ffmpeg {@code -progress} output
|
|
||||||
* @param progress the tracker to update
|
|
||||||
* @param length the total media length in seconds, used as the progress denominator
|
|
||||||
*/
|
|
||||||
public void setProgress(String line, ProgressTracker progress, float length) {
|
public void setProgress(String line, ProgressTracker progress, float length) {
|
||||||
Matcher matcher = timePattern.matcher(line);
|
Matcher matcher = timePattern.matcher(line);
|
||||||
if (matcher.find()) {
|
if (matcher.find()) {
|
||||||
|
|||||||
@@ -27,17 +27,6 @@ public class CompressionService {
|
|||||||
this.commandRunner = commandRunner;
|
this.commandRunner = commandRunner;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Compresses (and optionally trims/rescales) a video with ffmpeg on a background executor.
|
|
||||||
* <p>
|
|
||||||
* Runs asynchronously; failures complete the returned future exceptionally rather than throwing.
|
|
||||||
*
|
|
||||||
* @param inputFile the source video file
|
|
||||||
* @param outputFile the destination for the compressed output
|
|
||||||
* @param clipOptions the trim window, target resolution/fps, and size driving the ffmpeg command
|
|
||||||
* @param progress the tracker updated as encoding progresses
|
|
||||||
* @return a future completing with the ffmpeg output, or exceptionally on failure
|
|
||||||
*/
|
|
||||||
@Async("ffmpegTaskExecutor")
|
@Async("ffmpegTaskExecutor")
|
||||||
public CompletableFuture<CommandOutput> compress(Path inputFile,
|
public CompletableFuture<CommandOutput> compress(Path inputFile,
|
||||||
Path outputFile,
|
Path outputFile,
|
||||||
|
|||||||
@@ -26,14 +26,6 @@ public class MetadataService {
|
|||||||
this.commandRunner = commandRunner;
|
this.commandRunner = commandRunner;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Probes a video file with ffprobe and returns its metadata (duration, dimensions, fps, size).
|
|
||||||
* <p>
|
|
||||||
* Runs asynchronously; failures complete the returned future exceptionally rather than throwing.
|
|
||||||
*
|
|
||||||
* @param file the video file to probe
|
|
||||||
* @return a future completing with the parsed {@link ClipOptions}, or exceptionally on failure
|
|
||||||
*/
|
|
||||||
@Async("ffmpegTaskExecutor")
|
@Async("ffmpegTaskExecutor")
|
||||||
public CompletableFuture<ClipOptions> getVideoMetadata(Path file) {
|
public CompletableFuture<ClipOptions> getVideoMetadata(Path file) {
|
||||||
logger.info("Getting metadata for file {}", file);
|
logger.info("Getting metadata for file {}", file);
|
||||||
|
|||||||
@@ -21,17 +21,6 @@ public class RemuxService {
|
|||||||
this.commandRunner = commandRunner;
|
this.commandRunner = commandRunner;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-encodes a video to H.264/AAC in an MP4 container with ffmpeg on a background executor.
|
|
||||||
* <p>
|
|
||||||
* Runs asynchronously; failures complete the returned future exceptionally rather than throwing.
|
|
||||||
*
|
|
||||||
* @param inputFile the source video file
|
|
||||||
* @param outputFile the destination for the remuxed MP4
|
|
||||||
* @param remuxProgress the tracker updated as encoding progresses
|
|
||||||
* @param length the media length in seconds, used to compute progress
|
|
||||||
* @return a future completing with the ffmpeg output, or exceptionally on failure
|
|
||||||
*/
|
|
||||||
@Async("ffmpegTaskExecutor")
|
@Async("ffmpegTaskExecutor")
|
||||||
public CompletableFuture<CommandOutput> remux(File inputFile,
|
public CompletableFuture<CommandOutput> remux(File inputFile,
|
||||||
File outputFile,
|
File outputFile,
|
||||||
|
|||||||
@@ -11,161 +11,82 @@ import java.io.IOException;
|
|||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.ExecutionException;
|
import java.util.stream.Collectors;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts and saves a trimmed section of a stream from its recorded HLS
|
* Service responsible for performing stream-related media actions, such as
|
||||||
* segments.
|
* extracting and saving a trimmed section of video from a sequence of
|
||||||
|
* recorded segments.
|
||||||
* <p>
|
* <p>
|
||||||
* The usual flow is {@link #selectSegments} to resolve the segments covering a
|
* Operations in this service are backed by {@code ffmpeg} and run
|
||||||
* time range into a {@link SegmentSelection}, then {@link #saveSection} to mux
|
* asynchronously on a dedicated executor so that potentially long-running
|
||||||
* that selection to a file. Both are backed by {@code ffmpeg} and run on a
|
* encoding/concatenation work does not block request-handling threads.
|
||||||
* dedicated executor so long-running encodes never block request threads.
|
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class StreamActionsService {
|
public class StreamActionsService {
|
||||||
private static final Logger logger = LoggerFactory.getLogger(StreamActionsService.class);
|
private static final Logger logger = LoggerFactory.getLogger(StreamActionsService.class);
|
||||||
private final CommandRunner commandRunner;
|
private final CommandRunner commandRunner;
|
||||||
private final MetadataService metadataService;
|
|
||||||
|
|
||||||
public StreamActionsService(CommandRunner commandRunner, MetadataService metadataService) {
|
private static final int HLS_SEGMENT_LENGTH = 3;
|
||||||
|
|
||||||
|
public StreamActionsService(CommandRunner commandRunner) {
|
||||||
this.commandRunner = commandRunner;
|
this.commandRunner = commandRunner;
|
||||||
this.metadataService = metadataService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A resolved section of a stream, ready to be muxed by {@link #saveSection}.
|
* Concatenates a list of video segments and extracts a trimmed section
|
||||||
*
|
* from the result, writing the output to {@code outputFile}.
|
||||||
* @param segments the segments to concatenate, in playback order; never empty
|
|
||||||
* @param trimOffset seconds to trim from the start of {@code segments.getFirst()}
|
|
||||||
* so playback begins at the requested moment, or {@code 0}
|
|
||||||
* when the first segment already starts there
|
|
||||||
* @param duration seconds of footage to keep, measured from the trim point.
|
|
||||||
* When the requested start fell in a recording gap and the
|
|
||||||
* leading segment was dropped, this is measured from the
|
|
||||||
* later real start so the section still ends where requested
|
|
||||||
*/
|
|
||||||
public record SegmentSelection(List<Path> segments, float trimOffset, float duration) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves the recorded {@code .ts} segments covering {@code [startTime, endTime)}
|
|
||||||
* into a {@link SegmentSelection} ready for {@link #saveSection}.
|
|
||||||
* <p>
|
* <p>
|
||||||
* The trim offset into the first segment is derived from {@code startTime}. If
|
* Failures (process launch errors, ffmpeg failures, interruption) are
|
||||||
* that start falls in a recording gap after the earliest overlapping segment
|
* surfaced via the returned future rather than thrown, in line with
|
||||||
* (so the segment holds no footage for it), the segment is dropped and the
|
* normal {@link CompletableFuture} conventions — callers should use
|
||||||
* selection instead begins at the next recorded footage.
|
* {@code .exceptionally()} / {@code .handle()} rather than a try/catch.
|
||||||
*
|
*
|
||||||
* @param streamDirectory directory holding the stream's {@code .ts} segments
|
* @param segments the ordered list of segment files to concatenate,
|
||||||
* @param startTime inclusive start of the requested section
|
* in playback order
|
||||||
* @param endTime exclusive end of the requested section; must be after {@code startTime}
|
* @param trimOffset the offset, in seconds, from the start of the
|
||||||
* @return the segments, trim offset, and duration describing the section
|
* concatenated stream at which to begin the trimmed
|
||||||
* @throws IllegalArgumentException if {@code startTime} is not before {@code endTime},
|
* section
|
||||||
* or no segment holds footage for the range
|
* @param duration the duration, in seconds, of the trimmed section
|
||||||
* @throws IOException if listing the directory or probing a segment fails
|
* to extract
|
||||||
*/
|
* @param outputFile the destination file the trimmed section will be
|
||||||
public SegmentSelection selectSegments(Path streamDirectory, Instant startTime, Instant endTime) throws IOException {
|
* written to; overwritten if it already exists
|
||||||
List<Path> segments = findOverlappingSegments(streamDirectory, startTime, endTime);
|
* @return a {@link CompletableFuture} that completes with the ffmpeg
|
||||||
if (segments.isEmpty()) {
|
* command output on success, or completes exceptionally on failure
|
||||||
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);
|
|
||||||
|
|
||||||
// A first segment shorter than the trim offset means the requested start
|
|
||||||
// lands in a gap after it; drop it and begin at the next recorded footage.
|
|
||||||
if (trimOffset > 0 && probeDurationSeconds(segments.getFirst()) < trimOffset) {
|
|
||||||
segments.removeFirst();
|
|
||||||
if (segments.isEmpty()) {
|
|
||||||
throw new IllegalArgumentException("No recorded footage at the requested start time");
|
|
||||||
}
|
|
||||||
firstMs = parseTimestampMs(segments.getFirst());
|
|
||||||
trimOffset = 0f;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Measure duration from where playback actually begins so a dropped
|
|
||||||
// leading segment shortens the section rather than overrunning endTime.
|
|
||||||
long effectiveStartMs = firstMs + (long) (trimOffset * 1000);
|
|
||||||
float duration = (endTime.toEpochMilli() - effectiveStartMs) / 1000f;
|
|
||||||
|
|
||||||
return new SegmentSelection(segments, trimOffset, duration);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Muxes a {@link SegmentSelection} to {@code outputFile}, trimming the leading
|
|
||||||
* segment to {@code selection.trimOffset()} and keeping {@code selection.duration()}
|
|
||||||
* seconds of footage.
|
|
||||||
* <p>
|
|
||||||
* Runs asynchronously; failures (process-launch errors, ffmpeg failures,
|
|
||||||
* interruption) complete the returned future exceptionally rather than being
|
|
||||||
* thrown, so callers should use {@link CompletableFuture#exceptionally} or
|
|
||||||
* {@link CompletableFuture#handle} rather than try/catch.
|
|
||||||
*
|
|
||||||
* @param selection the segments, trim offset, and duration to write
|
|
||||||
* (see {@link #selectSegments})
|
|
||||||
* @param outputFile destination for the muxed section; overwritten if present
|
|
||||||
* @param progressTracker receives progress updates as ffmpeg runs
|
|
||||||
* @return a future completing with the ffmpeg output, or exceptionally on failure
|
|
||||||
*/
|
*/
|
||||||
@Async("ffmpegTaskExecutor")
|
@Async("ffmpegTaskExecutor")
|
||||||
public CompletableFuture<CommandOutput> saveSection(
|
public CompletableFuture<CommandOutput> saveSection(
|
||||||
SegmentSelection selection,
|
List<Path> segments,
|
||||||
|
float trimOffset,
|
||||||
|
float duration,
|
||||||
Path outputFile,
|
Path outputFile,
|
||||||
ProgressTracker progressTracker
|
ProgressTracker progressTracker
|
||||||
) {
|
) {
|
||||||
List<Path> segments = selection.segments();
|
|
||||||
float trimOffset = selection.trimOffset();
|
|
||||||
float duration = selection.duration();
|
|
||||||
|
|
||||||
if (segments.isEmpty()) {
|
|
||||||
return CompletableFuture.failedFuture(new IllegalArgumentException("Selection must contain at least one segment"));
|
|
||||||
}
|
|
||||||
if (trimOffset < 0 || duration <= 0) {
|
|
||||||
return CompletableFuture.failedFuture(new IllegalArgumentException(
|
|
||||||
"trimOffset must be >= 0 and duration must be > 0 (got " + trimOffset + ", " + duration + ")"));
|
|
||||||
}
|
|
||||||
|
|
||||||
Path headFragment = null;
|
|
||||||
Path concatList = null;
|
|
||||||
try {
|
try {
|
||||||
// Only the first segment is trimmed, so re-encode just that fragment and
|
String concatInput = segments.stream()
|
||||||
// stream-copy the rest. A zero offset means the first segment already
|
.map(p -> p.toAbsolutePath().toString())
|
||||||
// starts at the requested point, so re-encoding it would be wasted work.
|
.collect(Collectors.joining("|"));
|
||||||
List<Path> fragments;
|
|
||||||
if (trimOffset > 0) {
|
|
||||||
headFragment = Files.createTempFile("reencode-section-", ".ts");
|
|
||||||
encodeHead(segments.getFirst(), trimOffset, headFragment);
|
|
||||||
|
|
||||||
fragments = new ArrayList<>();
|
if (concatInput.isEmpty()) {
|
||||||
fragments.add(headFragment);
|
throw new IllegalStateException("Must have at least one segment");
|
||||||
fragments.addAll(segments.subList(1, segments.size()));
|
|
||||||
} else {
|
|
||||||
fragments = segments;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The concat demuxer (unlike the concat: byte protocol) rebases each
|
|
||||||
// fragment's timestamps to run continuously across the joins, absorbing
|
|
||||||
// the discontinuity between the PTS-0 head and the copied fragments.
|
|
||||||
concatList = writeConcatList(fragments);
|
|
||||||
|
|
||||||
List<String> command = List.of(
|
List<String> command = List.of(
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-y",
|
"-y",
|
||||||
"-f", "concat",
|
"-i", "concat:" + concatInput,
|
||||||
"-safe", "0",
|
"-ss", String.valueOf(trimOffset),
|
||||||
"-i", concatList.toAbsolutePath().toString(),
|
|
||||||
"-t", String.valueOf(duration),
|
"-t", String.valueOf(duration),
|
||||||
"-c", "copy",
|
"-c", "copy",
|
||||||
outputFile.toAbsolutePath().toString()
|
outputFile.toAbsolutePath().toString()
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info("Saving section ({} segments) to '{}'", segments.size(), outputFile);
|
logger.info("Saving section with ({} segments) to '{}'",
|
||||||
|
segments.size(), outputFile);
|
||||||
CommandOutput output = commandRunner.run(command, line -> commandRunner.setProgress(line, progressTracker, duration));
|
CommandOutput output = commandRunner.run(command, line -> commandRunner.setProgress(line, progressTracker, duration));
|
||||||
|
|
||||||
progressTracker.markComplete();
|
progressTracker.markComplete();
|
||||||
@@ -180,22 +101,30 @@ public class StreamActionsService {
|
|||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
logger.error("Failed to save section to '{}': {}", outputFile, e.toString());
|
logger.error("Failed to save section to '{}': {}", outputFile, e.toString());
|
||||||
return CompletableFuture.failedFuture(e);
|
return CompletableFuture.failedFuture(e);
|
||||||
} finally {
|
|
||||||
deleteTempQuietly(headFragment);
|
|
||||||
deleteTempQuietly(concatList);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public List<Path> getSegmentsInRange(Path streamDirectory, Instant startTime, Instant endTime) throws IOException {
|
||||||
* Parses the start timestamp encoded in an HLS segment's filename (e.g.
|
long startMs = startTime.toEpochMilli();
|
||||||
* {@code 1712345678.ts}). nginx names fragments in whole seconds, so
|
long endMs = endTime.toEpochMilli();
|
||||||
* second-scale values are scaled up to milliseconds.
|
|
||||||
*
|
if (endTime.toEpochMilli() <= startTime.toEpochMilli()) {
|
||||||
* @param path a {@code .ts} segment path
|
throw new IllegalArgumentException("Start time must be before end time");
|
||||||
* @return the segment's start time in epoch milliseconds
|
}
|
||||||
* @throws IllegalArgumentException if {@code path} is not a {@code .ts} file
|
|
||||||
* @throws NumberFormatException if the filename stem is not numeric
|
try (Stream<Path> files = Files.list(streamDirectory)) {
|
||||||
*/
|
return files
|
||||||
|
.filter(p -> p.getFileName().toString().endsWith(".ts"))
|
||||||
|
.filter(Files::isRegularFile)
|
||||||
|
.filter(p -> {
|
||||||
|
long segMs = parseTimestampMs(p);
|
||||||
|
return segMs < endMs && (segMs + HLS_SEGMENT_LENGTH * 1000) > startMs;
|
||||||
|
})
|
||||||
|
.sorted(Comparator.comparingLong(this::parseTimestampMs))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public long parseTimestampMs(Path path) {
|
public long parseTimestampMs(Path path) {
|
||||||
String fullName = path.getFileName().toString();
|
String fullName = path.getFileName().toString();
|
||||||
|
|
||||||
@@ -205,104 +134,7 @@ public class StreamActionsService {
|
|||||||
|
|
||||||
String name = fullName.substring(0, fullName.length() - 3);
|
String name = fullName.substring(0, fullName.length() - 3);
|
||||||
long value = Long.parseLong(name);
|
long value = Long.parseLong(name);
|
||||||
|
// nginx hls_fragment_naming system uses seconds; convert to ms
|
||||||
return value < 1_000_000_000_000L ? value * 1000L : value;
|
return value < 1_000_000_000_000L ? value * 1000L : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Lists the {@code .ts} segments whose playback interval overlaps
|
|
||||||
* {@code [startTime, endTime)}, in playback order. A segment spans from its
|
|
||||||
* own timestamp up to the next segment's timestamp (or open-ended for the last).
|
|
||||||
*/
|
|
||||||
private List<Path> findOverlappingSegments(Path streamDirectory, Instant startTime, Instant endTime) throws IOException {
|
|
||||||
long startMs = startTime.toEpochMilli();
|
|
||||||
long endMs = endTime.toEpochMilli();
|
|
||||||
|
|
||||||
if (endMs <= startMs) {
|
|
||||||
throw new IllegalArgumentException("Start time must be before end time");
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Path> overlapping = new ArrayList<>();
|
|
||||||
|
|
||||||
try (Stream<Path> files = Files.list(streamDirectory)) {
|
|
||||||
List<Path> streamFiles = files
|
|
||||||
.filter(p -> p.getFileName().toString().endsWith(".ts"))
|
|
||||||
.filter(Files::isRegularFile)
|
|
||||||
.sorted(Comparator.comparingLong(this::parseTimestampMs))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
for (int i = 0; i < streamFiles.size(); i++) {
|
|
||||||
Path streamFile = streamFiles.get(i);
|
|
||||||
|
|
||||||
long segmentStart = parseTimestampMs(streamFile);
|
|
||||||
long segmentEnd = (i + 1 < streamFiles.size())
|
|
||||||
? parseTimestampMs(streamFiles.get(i + 1))
|
|
||||||
: Long.MAX_VALUE;
|
|
||||||
|
|
||||||
if (segmentStart < endMs && segmentEnd > startMs) {
|
|
||||||
overlapping.add(streamFile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return overlapping;
|
|
||||||
}
|
|
||||||
|
|
||||||
private float probeDurationSeconds(Path segment) throws IOException {
|
|
||||||
try {
|
|
||||||
return metadataService.getVideoMetadata(segment).get().getDuration();
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
throw new IOException("Interrupted while probing segment duration: " + segment, e);
|
|
||||||
} catch (ExecutionException e) {
|
|
||||||
throw new IOException("Failed to probe segment duration: " + segment, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void encodeHead(Path firstSegment, float trimOffset, Path head)
|
|
||||||
throws IOException, InterruptedException {
|
|
||||||
|
|
||||||
List<String> command = List.of(
|
|
||||||
"ffmpeg",
|
|
||||||
"-y",
|
|
||||||
"-i", firstSegment.toAbsolutePath().toString(),
|
|
||||||
"-ss", String.valueOf(trimOffset),
|
|
||||||
"-c:v", "libx264",
|
|
||||||
"-preset", "veryfast",
|
|
||||||
"-crf", "20",
|
|
||||||
"-pix_fmt", "yuv420p",
|
|
||||||
"-c:a", "aac",
|
|
||||||
"-b:a", "160k",
|
|
||||||
"-muxdelay", "0",
|
|
||||||
"-muxpreload", "0",
|
|
||||||
"-f", "mpegts",
|
|
||||||
head.toAbsolutePath().toString()
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.debug("Re-encoding head fragment '{}' from {}s", firstSegment, trimOffset);
|
|
||||||
commandRunner.run(command, line -> { /* head pass is short; no progress reporting */ });
|
|
||||||
|
|
||||||
if (!Files.exists(head) || Files.size(head) == 0) {
|
|
||||||
throw new IOException("Head re-encode produced no output — trimOffset " + trimOffset
|
|
||||||
+ "s may exceed the duration of " + firstSegment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Path writeConcatList(List<Path> fragments) throws IOException {
|
|
||||||
Path listFile = Files.createTempFile("concat-list-", ".txt");
|
|
||||||
List<String> lines = fragments.stream()
|
|
||||||
.map(p -> "file '" + p.toAbsolutePath().toString().replace("'", "'\\''") + "'")
|
|
||||||
.toList();
|
|
||||||
Files.write(listFile, lines);
|
|
||||||
return listFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void deleteTempQuietly(Path tempFile) {
|
|
||||||
if (tempFile != null) {
|
|
||||||
try {
|
|
||||||
Files.deleteIfExists(tempFile);
|
|
||||||
} catch (IOException e) {
|
|
||||||
logger.warn("Failed to delete temp file '{}': {}", tempFile, e.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,16 +20,6 @@ public class ThumbnailService {
|
|||||||
this.commandRunner = commandRunner;
|
this.commandRunner = commandRunner;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts a single frame from a video as a thumbnail image with ffmpeg on a background executor.
|
|
||||||
* <p>
|
|
||||||
* Runs asynchronously; failures complete the returned future exceptionally rather than throwing.
|
|
||||||
*
|
|
||||||
* @param inputFile the source video file
|
|
||||||
* @param outputFile the destination for the thumbnail image
|
|
||||||
* @param timeInVideo the timestamp in seconds at which to capture the frame
|
|
||||||
* @return a future completing with the ffmpeg output, or exceptionally on failure
|
|
||||||
*/
|
|
||||||
@Async("ffmpegTaskExecutor")
|
@Async("ffmpegTaskExecutor")
|
||||||
public CompletableFuture<CommandOutput> createThumbnail(Path inputFile, Path outputFile, Float timeInVideo) {
|
public CompletableFuture<CommandOutput> createThumbnail(Path inputFile, Path outputFile, Float timeInVideo) {
|
||||||
logger.info("Creating thumbnail at {} seconds", timeInVideo);
|
logger.info("Creating thumbnail at {} seconds", timeInVideo);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ spring.datasource.username=${POSTGRES_USER}
|
|||||||
spring.datasource.password=${POSTGRES_PASSWORD}
|
spring.datasource.password=${POSTGRES_PASSWORD}
|
||||||
spring.datasource.driver-class-name=org.postgresql.Driver
|
spring.datasource.driver-class-name=org.postgresql.Driver
|
||||||
spring.jpa.hibernate.ddl-auto=update
|
spring.jpa.hibernate.ddl-auto=update
|
||||||
spring.jpa.show-sql=false
|
spring.jpa.show-sql=true
|
||||||
spring.sql.init.mode=always
|
spring.sql.init.mode=always
|
||||||
spring.sql.init.schema-locations=classpath:db/schema.sql
|
spring.sql.init.schema-locations=classpath:db/schema.sql
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.ddf.vodsystem.services.media;
|
package com.ddf.vodsystem.services.media;
|
||||||
|
|
||||||
import com.ddf.vodsystem.dto.ClipOptions;
|
|
||||||
import com.ddf.vodsystem.dto.CommandOutput;
|
import com.ddf.vodsystem.dto.CommandOutput;
|
||||||
import com.ddf.vodsystem.dto.ProgressTracker;
|
import com.ddf.vodsystem.dto.ProgressTracker;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
@@ -18,7 +17,6 @@ import java.nio.file.NoSuchFileException;
|
|||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.CompletableFuture;
|
|
||||||
import java.util.concurrent.ExecutionException;
|
import java.util.concurrent.ExecutionException;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
@@ -33,9 +31,6 @@ class StreamActionsServiceTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private CommandRunner commandRunner;
|
private CommandRunner commandRunner;
|
||||||
|
|
||||||
@Mock
|
|
||||||
private MetadataService metadataService;
|
|
||||||
|
|
||||||
private StreamActionsService service;
|
private StreamActionsService service;
|
||||||
|
|
||||||
@TempDir
|
@TempDir
|
||||||
@@ -43,163 +38,155 @@ class StreamActionsServiceTest {
|
|||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
service = new StreamActionsService(commandRunner, metadataService);
|
service = new StreamActionsService(commandRunner);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void createSegment(String name) throws IOException {
|
private void createSegment(String name) throws IOException {
|
||||||
Files.createFile(tempDir.resolve(name));
|
Files.createFile(tempDir.resolve(name));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stubs the (async) metadata probe so {@code segments.getFirst()} reports {@code seconds}. */
|
|
||||||
private void stubHeadDuration(float seconds) {
|
|
||||||
ClipOptions meta = new ClipOptions();
|
|
||||||
meta.setDuration(seconds);
|
|
||||||
when(metadataService.getVideoMetadata(any())).thenReturn(CompletableFuture.completedFuture(meta));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stubs the two-arg {@link CommandRunner#run} so the head re-encode pass
|
|
||||||
* actually writes a non-empty file at its output path — {@code encodeHead}
|
|
||||||
* verifies its output exists and is non-empty before continuing. The concat
|
|
||||||
* pass (no {@code mpegts} muxer) is left untouched. Returns {@code output}
|
|
||||||
* for both invocations.
|
|
||||||
*/
|
|
||||||
private void stubRunnerProducingHead(CommandOutput output) throws IOException, InterruptedException {
|
|
||||||
when(commandRunner.run(anyList(), any())).thenAnswer(inv -> {
|
|
||||||
List<String> cmd = inv.getArgument(0);
|
|
||||||
if (cmd.contains("mpegts")) {
|
|
||||||
Files.writeString(Path.of(cmd.getLast()), "x");
|
|
||||||
}
|
|
||||||
return output;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// selectSegments: missing / invalid directories
|
// getSegmentsInRange: missing / invalid directories
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void selectSegments_directoryDoesNotExist_throwsIOException() {
|
void getSegmentsInRange_directoryDoesNotExist_throwsIOException() {
|
||||||
Path missing = tempDir.resolve("does-not-exist");
|
Path missing = tempDir.resolve("does-not-exist");
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.selectSegments(missing, Instant.EPOCH, Instant.now()))
|
assertThatThrownBy(() -> service.getSegmentsInRange(missing, Instant.EPOCH, Instant.now()))
|
||||||
.isInstanceOf(NoSuchFileException.class);
|
.isInstanceOf(NoSuchFileException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void selectSegments_pathIsAFileNotADirectory_throwsIOException() throws IOException {
|
void getSegmentsInRange_pathIsAFileNotADirectory_throwsIOException() throws IOException {
|
||||||
Path file = tempDir.resolve("not-a-directory.txt");
|
Path file = tempDir.resolve("not-a-directory.txt");
|
||||||
Files.createFile(file);
|
Files.createFile(file);
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.selectSegments(file, Instant.EPOCH, Instant.now()))
|
assertThatThrownBy(() -> service.getSegmentsInRange(file, Instant.EPOCH, Instant.now()))
|
||||||
.isInstanceOf(IOException.class);
|
.isInstanceOf(IOException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getSegmentsInRange_emptyDirectory_returnsEmptyList() throws IOException {
|
||||||
|
List<Path> result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(100_000));
|
||||||
|
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// selectSegments: filename filtering
|
// getSegmentsInRange: filename filtering
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void selectSegments_ignoresFilesNotEndingInTs() throws IOException {
|
void getSegmentsInRange_ignoresFilesNotEndingInTs() throws IOException {
|
||||||
createSegment("1.ts");
|
createSegment("1.ts");
|
||||||
createSegment("playlist.m3u8");
|
createSegment("playlist.m3u8");
|
||||||
createSegment("1.ts.tmp");
|
createSegment("1.ts.tmp");
|
||||||
createSegment("readme.txt");
|
createSegment("readme.txt");
|
||||||
|
|
||||||
var result = service.selectSegments(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(5000)).segments();
|
List<Path> result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(5000));
|
||||||
|
|
||||||
assertThat(result).extracting(p -> p.getFileName().toString()).containsExactly("1.ts");
|
assertThat(result).extracting(p -> p.getFileName().toString()).containsExactly("1.ts");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void selectSegments_directoryNamedLikeASegment_isNotIncluded() throws IOException {
|
void getSegmentsInRange_directoryNamedLikeASegment_isNotIncluded() throws IOException {
|
||||||
Files.createDirectory(tempDir.resolve("5.ts"));
|
Files.createDirectory(tempDir.resolve("5.ts"));
|
||||||
|
|
||||||
// the directory is the only ".ts" entry; excluding it leaves nothing to select
|
List<Path> result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(10_000));
|
||||||
assertThatThrownBy(() ->
|
|
||||||
service.selectSegments(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(10_000)))
|
assertThat(result).isEmpty();
|
||||||
.isInstanceOf(IllegalArgumentException.class);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void selectSegments_malformedNonNumericFilename_throwsNumberFormatException() throws IOException {
|
void getSegmentsInRange_malformedNonNumericFilename_throwsNumberFormatException() throws IOException {
|
||||||
// A real HLS fragment name is always numeric. A corrupted or partially-written
|
// A real HLS fragment name is always numeric. A corrupted or partially-written
|
||||||
// file (e.g. "index.ts") crashes the whole listing instead of just being skipped.
|
// file (e.g. "index.ts") crashes the whole listing instead of just being skipped.
|
||||||
createSegment("index.ts");
|
createSegment("index.ts");
|
||||||
|
|
||||||
assertThatThrownBy(() ->
|
assertThatThrownBy(() ->
|
||||||
service.selectSegments(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(10_000)))
|
service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(10_000)))
|
||||||
.isInstanceOf(NumberFormatException.class);
|
.isInstanceOf(NumberFormatException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void selectSegments_emptyFilenameBeforeExtension_throwsNumberFormatException() throws IOException {
|
void getSegmentsInRange_emptyFilenameBeforeExtension_throwsNumberFormatException() throws IOException {
|
||||||
createSegment(".ts");
|
createSegment(".ts");
|
||||||
|
|
||||||
assertThatThrownBy(() ->
|
assertThatThrownBy(() ->
|
||||||
service.selectSegments(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(10_000)))
|
service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(10_000)))
|
||||||
.isInstanceOf(NumberFormatException.class);
|
.isInstanceOf(NumberFormatException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// selectSegments: range-boundary arithmetic
|
// getSegmentsInRange: range-boundary arithmetic
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void selectSegments_segmentEndsExactlyAtRangeStart_isExcluded() throws IOException {
|
void getSegmentsInRange_segmentEndsExactlyAtRangeStart_isExcluded() throws IOException {
|
||||||
|
// "0.ts" spans [0, 3000)ms. A request starting exactly at 3000ms should not
|
||||||
|
// pull in a segment that ended the instant before.
|
||||||
createSegment("0.ts");
|
createSegment("0.ts");
|
||||||
createSegment("3.ts");
|
|
||||||
|
|
||||||
var result = service.selectSegments(tempDir, Instant.ofEpochMilli(3000), Instant.ofEpochMilli(10_000)).segments();
|
List<Path> result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(3000), Instant.ofEpochMilli(10_000));
|
||||||
|
|
||||||
assertThat(result).extracting(p -> p.getFileName().toString())
|
assertThat(result).isEmpty();
|
||||||
.containsExactly("3.ts");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void selectSegments_segmentOverlapsRangeStartByOneMs_isIncluded() throws IOException {
|
void getSegmentsInRange_segmentOverlapsRangeStartByOneMs_isIncluded() throws IOException {
|
||||||
createSegment("0.ts"); // spans [0, 3000)
|
createSegment("0.ts"); // spans [0, 3000)
|
||||||
stubHeadDuration(3f); // start 2999ms trims into 0.ts, so its duration is probed
|
|
||||||
|
|
||||||
var result = service.selectSegments(tempDir, Instant.ofEpochMilli(2999), Instant.ofEpochMilli(10_000)).segments();
|
List<Path> result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(2999), Instant.ofEpochMilli(10_000));
|
||||||
|
|
||||||
assertThat(result).hasSize(1);
|
assertThat(result).hasSize(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void selectSegments_segmentStartsExactlyAtRangeEnd_isExcluded() throws IOException {
|
void getSegmentsInRange_segmentStartsExactlyAtRangeEnd_isExcluded() throws IOException {
|
||||||
createSegment("5.ts"); // starts at 5000ms
|
createSegment("5.ts"); // starts at 5000ms
|
||||||
|
|
||||||
assertThatThrownBy(() ->
|
List<Path> result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(5000));
|
||||||
service.selectSegments(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(5000)))
|
|
||||||
.isInstanceOf(IllegalArgumentException.class);
|
assertThat(result).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void selectSegments_segmentStartsOneMsBeforeRangeEnd_isIncluded() throws IOException {
|
void getSegmentsInRange_segmentStartsOneMsBeforeRangeEnd_isIncluded() throws IOException {
|
||||||
createSegment("5.ts"); // starts at 5000ms
|
createSegment("5.ts"); // starts at 5000ms
|
||||||
|
|
||||||
var result = service.selectSegments(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(5001)).segments();
|
List<Path> result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(5001));
|
||||||
|
|
||||||
assertThat(result).hasSize(1);
|
assertThat(result).hasSize(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void selectSegments_startAfterEnd_throwsIllegalArgument() throws IOException {
|
void getSegmentsInRange_startAfterEnd_throwsIllegalArgument() throws IOException {
|
||||||
createSegment("10.ts");
|
createSegment("10.ts");
|
||||||
|
|
||||||
assertThatThrownBy(() ->
|
assertThatThrownBy(() ->
|
||||||
service.selectSegments(tempDir, Instant.ofEpochMilli(20_000), Instant.ofEpochMilli(10_000)))
|
service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(20_000), Instant.ofEpochMilli(10_000)))
|
||||||
.isInstanceOf(IllegalArgumentException.class);
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void selectSegments_segmentsOutOfOrder_returnedInOrder() throws IOException {
|
void getSegmentsInRange_segmentsWhollyOutsideRange_areExcluded() throws IOException {
|
||||||
|
createSegment("0.ts"); // [0, 3000)
|
||||||
|
createSegment("100.ts"); // [100000, 103000)
|
||||||
|
|
||||||
|
List<Path> result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(50_000), Instant.ofEpochMilli(60_000));
|
||||||
|
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getSegmentsInRange_segmentsOutOfOrder_returnedInOrder() throws IOException {
|
||||||
createSegment("20.ts");
|
createSegment("20.ts");
|
||||||
createSegment("10.ts");
|
createSegment("10.ts");
|
||||||
createSegment("0.ts");
|
createSegment("0.ts");
|
||||||
|
|
||||||
var result = service.selectSegments(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(25_000)).segments();
|
List<Path> result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(25_000));
|
||||||
|
|
||||||
assertThat(result)
|
assertThat(result)
|
||||||
.extracting(p -> p.getFileName().toString())
|
.extracting(p -> p.getFileName().toString())
|
||||||
@@ -207,98 +194,31 @@ class StreamActionsServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void selectSegments_mixOfOverlappingAndNonOverlappingSegments_returnsOnlyOverlapping() throws IOException {
|
void getSegmentsInRange_mixOfOverlappingAndNonOverlappingSegments_returnsOnlyOverlapping() throws IOException {
|
||||||
createSegment("0.ts"); // [0, 3000)
|
createSegment("0.ts"); // [0, 3000)
|
||||||
createSegment("3.ts"); // [3000, 6000)
|
createSegment("3.ts"); // [3000, 6000)
|
||||||
createSegment("6.ts"); // [6000, 9000)
|
createSegment("6.ts"); // [6000, 9000)
|
||||||
createSegment("9.ts"); // [9000, 12000)
|
createSegment("9.ts"); // [9000, 12000)
|
||||||
stubHeadDuration(3f); // start 4000ms trims into 3.ts, so its duration is probed
|
|
||||||
|
|
||||||
var result = service.selectSegments(tempDir, Instant.ofEpochMilli(4000), Instant.ofEpochMilli(8000)).segments();
|
|
||||||
|
List<Path> result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(4000), Instant.ofEpochMilli(8000));
|
||||||
|
|
||||||
assertThat(result).extracting(p -> p.getFileName().toString())
|
assertThat(result).extracting(p -> p.getFileName().toString())
|
||||||
.containsExactly("3.ts", "6.ts");
|
.containsExactly("3.ts", "6.ts");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void selectSegments_resultsAreSortedByTimestampRegardlessOfDirectoryOrder() throws IOException {
|
void getSegmentsInRange_resultsAreSortedByTimestampRegardlessOfDirectoryOrder() throws IOException {
|
||||||
createSegment("30.ts");
|
createSegment("30.ts");
|
||||||
createSegment("10.ts");
|
createSegment("10.ts");
|
||||||
createSegment("20.ts");
|
createSegment("20.ts");
|
||||||
|
|
||||||
var result = service.selectSegments(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(100_000)).segments();
|
List<Path> result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(100_000));
|
||||||
|
|
||||||
assertThat(result).extracting(p -> p.getFileName().toString())
|
assertThat(result).extracting(p -> p.getFileName().toString())
|
||||||
.containsExactly("10.ts", "20.ts", "30.ts");
|
.containsExactly("10.ts", "20.ts", "30.ts");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
|
||||||
// selectSegments: head selection and trim-offset
|
|
||||||
// ---------------------------------------------------------------
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void selectSegments_startWithinFirstSegment_keepsItAndComputesOffset() throws IOException {
|
|
||||||
createSegment("30.ts");
|
|
||||||
createSegment("33.ts");
|
|
||||||
stubHeadDuration(3f); // 30.ts spans [30s, 33s)
|
|
||||||
|
|
||||||
var selection = service.selectSegments(tempDir, Instant.ofEpochMilli(31_500), Instant.ofEpochMilli(40_000));
|
|
||||||
|
|
||||||
assertThat(selection.segments()).extracting(p -> p.getFileName().toString())
|
|
||||||
.containsExactly("30.ts", "33.ts");
|
|
||||||
assertThat(selection.trimOffset()).isEqualTo(1.5f);
|
|
||||||
// effective start equals the requested 31.5s, so duration runs to endTime: 40 - 31.5
|
|
||||||
assertThat(selection.duration()).isEqualTo(8.5f);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void selectSegments_startInGapAfterFirstSegment_dropsItAndStartsAtNext() throws IOException {
|
|
||||||
createSegment("30.ts");
|
|
||||||
// user stops streaming; a gap follows 30.ts
|
|
||||||
createSegment("100.ts");
|
|
||||||
createSegment("110.ts");
|
|
||||||
stubHeadDuration(3f); // 30.ts only holds [30s, 33s); requested start 70s is past it
|
|
||||||
|
|
||||||
var selection = service.selectSegments(tempDir, Instant.ofEpochMilli(70_000), Instant.ofEpochMilli(111_000));
|
|
||||||
|
|
||||||
assertThat(selection.segments()).extracting(p -> p.getFileName().toString())
|
|
||||||
.containsExactly("100.ts", "110.ts");
|
|
||||||
assertThat(selection.trimOffset()).isZero();
|
|
||||||
// the dropped head shifts the effective start to 100s, so the duration
|
|
||||||
// shrinks to end at endTime (111 - 100 = 11s) instead of the 41s the
|
|
||||||
// original 70s->111s window would have implied
|
|
||||||
assertThat(selection.duration()).isEqualTo(11f);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void selectSegments_startPastOnlySegment_throwsIllegalArgument() throws IOException {
|
|
||||||
createSegment("0.ts");
|
|
||||||
stubHeadDuration(3f); // 0.ts holds [0s, 3s); requested start 50s has no footage
|
|
||||||
|
|
||||||
assertThatThrownBy(() ->
|
|
||||||
service.selectSegments(tempDir, Instant.ofEpochMilli(50_000), Instant.ofEpochMilli(60_000)))
|
|
||||||
.isInstanceOf(IllegalArgumentException.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void selectSegments_noSegmentsInRange_throwsIllegalArgument() {
|
|
||||||
assertThatThrownBy(() ->
|
|
||||||
service.selectSegments(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(10_000)))
|
|
||||||
.isInstanceOf(IllegalArgumentException.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void selectSegments_probeFailure_surfacesAsIOException() throws IOException {
|
|
||||||
createSegment("30.ts");
|
|
||||||
createSegment("100.ts");
|
|
||||||
when(metadataService.getVideoMetadata(any()))
|
|
||||||
.thenReturn(CompletableFuture.failedFuture(new IOException("ffprobe boom")));
|
|
||||||
|
|
||||||
assertThatThrownBy(() ->
|
|
||||||
service.selectSegments(tempDir, Instant.ofEpochMilli(70_000), Instant.ofEpochMilli(111_000)))
|
|
||||||
.isInstanceOf(IOException.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// parseTimestampMs: unit-based conversion
|
// parseTimestampMs: unit-based conversion
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
@@ -355,55 +275,24 @@ class StreamActionsServiceTest {
|
|||||||
ProgressTracker progress = new ProgressTracker();
|
ProgressTracker progress = new ProgressTracker();
|
||||||
CommandOutput commandOutput = new CommandOutput();
|
CommandOutput commandOutput = new CommandOutput();
|
||||||
|
|
||||||
stubRunnerProducingHead(commandOutput);
|
|
||||||
|
|
||||||
var future = service.saveSection(
|
|
||||||
new StreamActionsService.SegmentSelection(List.of(segmentA, segmentB), 5.0f, 12.0f), output, progress);
|
|
||||||
|
|
||||||
assertThat(future.get()).isSameAs(commandOutput);
|
|
||||||
assertThat(progress.isComplete()).isTrue();
|
|
||||||
|
|
||||||
// With a non-zero trim offset saveSection issues two ffmpeg passes: the
|
|
||||||
// head re-encode (trims the first segment via -ss) followed by the
|
|
||||||
// concat-demuxer stream copy (-f concat / -t / -c copy).
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
ArgumentCaptor<List<String>> captor = ArgumentCaptor.forClass(List.class);
|
|
||||||
Mockito.verify(commandRunner, Mockito.times(2)).run(captor.capture(), any());
|
|
||||||
List<String> headCommand = captor.getAllValues().get(0);
|
|
||||||
List<String> concatCommand = captor.getAllValues().get(1);
|
|
||||||
|
|
||||||
assertThat(headCommand).containsSequence("-ss", "5.0");
|
|
||||||
assertThat(concatCommand).containsSequence("-f", "concat");
|
|
||||||
assertThat(concatCommand).containsSequence("-t", "12.0");
|
|
||||||
assertThat(concatCommand).containsSequence("-c", "copy");
|
|
||||||
assertThat(concatCommand).endsWith(output.toAbsolutePath().toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void saveSection_zeroTrimOffset_skipsHeadReencodeAndRunsSingleConcatPass() throws Exception {
|
|
||||||
Path output = Path.of("/tmp/out/clip.mp4");
|
|
||||||
ProgressTracker progress = new ProgressTracker();
|
|
||||||
CommandOutput commandOutput = new CommandOutput();
|
|
||||||
|
|
||||||
when(commandRunner.run(anyList(), any())).thenReturn(commandOutput);
|
when(commandRunner.run(anyList(), any())).thenReturn(commandOutput);
|
||||||
|
|
||||||
var future = service.saveSection(
|
var future = service.saveSection(List.of(segmentA, segmentB), 5.0f, 12.0f, output, progress);
|
||||||
new StreamActionsService.SegmentSelection(
|
|
||||||
List.of(Path.of("/tmp/a.ts"), Path.of("/tmp/b.ts")), 0f, 10f), output, progress);
|
|
||||||
|
|
||||||
assertThat(future.get()).isSameAs(commandOutput);
|
assertThat(future.get()).isSameAs(commandOutput);
|
||||||
assertThat(progress.isComplete()).isTrue();
|
assertThat(progress.isComplete()).isTrue();
|
||||||
|
|
||||||
// trimOffset 0 means the first segment already starts at the requested point:
|
|
||||||
// no head re-encode pass, just a single concat-demuxer copy (no -ss).
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
ArgumentCaptor<List<String>> captor = ArgumentCaptor.forClass(List.class);
|
ArgumentCaptor<List<String>> captor = ArgumentCaptor.forClass(List.class);
|
||||||
Mockito.verify(commandRunner, Mockito.times(1)).run(captor.capture(), any());
|
Mockito.verify(commandRunner).run(captor.capture(), any());
|
||||||
List<String> command = captor.getValue();
|
List<String> command = captor.getValue();
|
||||||
|
|
||||||
assertThat(command).containsSequence("-f", "concat");
|
assertThat(command).containsSequence("-i",
|
||||||
assertThat(command).containsSequence("-t", "10.0");
|
"concat:" + segmentA.toAbsolutePath() + "|" + segmentB.toAbsolutePath());
|
||||||
assertThat(command).doesNotContain("-ss");
|
assertThat(command).containsSequence("-ss", "5.0");
|
||||||
|
assertThat(command).containsSequence("-t", "12.0");
|
||||||
|
assertThat(command).containsSequence("-c", "copy");
|
||||||
|
assertThat(command).endsWith(output.toAbsolutePath().toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -411,12 +300,9 @@ class StreamActionsServiceTest {
|
|||||||
Path output = Path.of("/tmp/out/clip.mp4");
|
Path output = Path.of("/tmp/out/clip.mp4");
|
||||||
ProgressTracker progress = new ProgressTracker();
|
ProgressTracker progress = new ProgressTracker();
|
||||||
|
|
||||||
// With a non-zero trim offset the head re-encode is the first ffmpeg pass;
|
|
||||||
// its failure must surface.
|
|
||||||
when(commandRunner.run(anyList(), any())).thenThrow(new IOException("ffmpeg boom"));
|
when(commandRunner.run(anyList(), any())).thenThrow(new IOException("ffmpeg boom"));
|
||||||
|
|
||||||
var future = service.saveSection(
|
var future = service.saveSection(List.of(Path.of("/tmp/a.ts")), 0f, 10f, output, progress);
|
||||||
new StreamActionsService.SegmentSelection(List.of(Path.of("/tmp/a.ts")), 2f, 10f), output, progress);
|
|
||||||
|
|
||||||
assertThatThrownBy(future::get)
|
assertThatThrownBy(future::get)
|
||||||
.isInstanceOf(ExecutionException.class)
|
.isInstanceOf(ExecutionException.class)
|
||||||
@@ -425,16 +311,15 @@ class StreamActionsServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void saveSection_emptySegmentList_completesExceptionallyWithIllegalArgumentException() {
|
void saveSection_emptySegmentList_completesExceptionallyWithIllegalStateException() {
|
||||||
Path output = Path.of("/tmp/out/clip.mp4");
|
Path output = Path.of("/tmp/out/clip.mp4");
|
||||||
ProgressTracker progress = new ProgressTracker();
|
ProgressTracker progress = new ProgressTracker();
|
||||||
|
|
||||||
var future = service.saveSection(
|
var future = service.saveSection(List.of(), 0f, 10f, output, progress);
|
||||||
new StreamActionsService.SegmentSelection(List.of(), 0f, 10f), output, progress);
|
|
||||||
|
|
||||||
assertThatThrownBy(future::get)
|
assertThatThrownBy(future::get)
|
||||||
.isInstanceOf(ExecutionException.class)
|
.isInstanceOf(ExecutionException.class)
|
||||||
.hasCauseInstanceOf(IllegalArgumentException.class);
|
.hasCauseInstanceOf(IllegalStateException.class);
|
||||||
assertThat(progress.isComplete()).isFalse();
|
assertThat(progress.isComplete()).isFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,10 +327,7 @@ class StreamActionsServiceTest {
|
|||||||
void saveSection_nullOutputFile_completesExceptionally() {
|
void saveSection_nullOutputFile_completesExceptionally() {
|
||||||
ProgressTracker progress = new ProgressTracker();
|
ProgressTracker progress = new ProgressTracker();
|
||||||
|
|
||||||
// trimOffset 0 skips the head pass; building the concat command dereferences
|
var future = service.saveSection(List.of(Path.of("/tmp/a.ts")), 0f, 10f, null, progress);
|
||||||
// the null output path, so no ffmpeg pass ever runs.
|
|
||||||
var future = service.saveSection(
|
|
||||||
new StreamActionsService.SegmentSelection(List.of(Path.of("/tmp/a.ts")), 0f, 10f), null, progress);
|
|
||||||
|
|
||||||
assertThatThrownBy(future::get)
|
assertThatThrownBy(future::get)
|
||||||
.isInstanceOf(ExecutionException.class)
|
.isInstanceOf(ExecutionException.class)
|
||||||
|
|||||||
Reference in New Issue
Block a user