Compare commits
1 Commits
feat/user-
...
b577fd9387
| Author | SHA1 | Date | |
|---|---|---|---|
| b577fd9387 |
@@ -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:
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ services:
|
|||||||
context: ./nginx
|
context: ./nginx
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: vod_rtmp
|
container_name: vod_rtmp
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- WEB_SERVER_URL=http://host.docker.internal:8080
|
- WEB_SERVER_URL=http://host.docker.internal:8080
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -42,7 +42,7 @@ services:
|
|||||||
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
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ RUN npm run build
|
|||||||
# --- Production stage ---
|
# --- Production stage ---
|
||||||
FROM nginx:1.27-alpine AS prod
|
FROM nginx:1.27-alpine AS prod
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
COPY frontend_nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
|
|
||||||
# Inject envs at runtime
|
# Inject envs at runtime
|
||||||
COPY env.sh /docker-entrypoint.d/40-env.sh
|
COPY env.sh /docker-entrypoint.d/40-env.sh
|
||||||
|
|||||||
54
frontend/README.md
Normal file
54
frontend/README.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default tseslint.config({
|
||||||
|
extends: [
|
||||||
|
// Remove ...tseslint.configs.recommended and replace with this
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
...tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
...tseslint.configs.stylisticTypeChecked,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
// other options...
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default tseslint.config({
|
||||||
|
plugins: {
|
||||||
|
// Add the react-x and react-dom plugins
|
||||||
|
'react-x': reactX,
|
||||||
|
'react-dom': reactDom,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// other rules...
|
||||||
|
// Enable its recommended typescript rules
|
||||||
|
...reactX.configs['recommended-typescript'].rules,
|
||||||
|
...reactDom.configs.recommended.rules,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,20 +9,6 @@
|
|||||||
<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">
|
<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>
|
|
||||||
// Apply the stored theme before first paint so a dark session never
|
|
||||||
// flashes light. Mirrors the logic in src/theme/ThemeProvider.tsx.
|
|
||||||
(function () {
|
|
||||||
try {
|
|
||||||
var stored = localStorage.getItem("theme");
|
|
||||||
var dark = stored === "dark" ||
|
|
||||||
(stored !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
|
||||||
document.documentElement.dataset.theme = dark ? "dark" : "light";
|
|
||||||
} catch (e) {
|
|
||||||
document.documentElement.dataset.theme = "light";
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
<script src="/env-config.js"></script>
|
<script src="/env-config.js"></script>
|
||||||
<title>The VoD System</title>
|
<title>The VoD System</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -16,10 +16,6 @@ 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 {config} from "./config.ts";
|
||||||
import NotFound from "./pages/NotFound.tsx";
|
|
||||||
import AuthProvider from "./auth/AuthProvider.tsx";
|
|
||||||
import ThemeProvider from "./theme/ThemeProvider.tsx";
|
|
||||||
import RequireAuth from "./auth/RequireAuth.tsx";
|
|
||||||
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -29,32 +25,23 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GoogleOAuthProvider clientId={config.googleClientId}>
|
<GoogleOAuthProvider clientId={config.googleClientId}>
|
||||||
<ThemeProvider>
|
<Router>
|
||||||
<AuthProvider>
|
<Routes>
|
||||||
<Router>
|
<Route element={<MainLayout />}>
|
||||||
<Routes>
|
<Route path="/" element={<Home />} />
|
||||||
<Route element={<MainLayout />}>
|
<Route path="/create" element={<ClipUpload />} />
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/create/:id" element={<ClipEdit />} />
|
||||||
<Route path="/create" element={<ClipUpload />} />
|
<Route path="/my-clips" element={<MyClips />} />
|
||||||
<Route path="/create/:id" element={<ClipEdit />} />
|
<Route path="/my-vods" element={<MyVods />} />
|
||||||
|
<Route path="/video/:id" element={<MediaPlayer noun="Clip" getMediaUrl={getClipMediaUrl} fetchDetails={getClipById} />} />
|
||||||
<Route element={<RequireAuth />}>
|
<Route path="/vod/:id" element={<MediaPlayer noun="VoD" getMediaUrl={getVodMediaUrl} fetchDetails={getVodById} />} />
|
||||||
<Route path="/my-clips" element={<MyClips />} />
|
<Route path="/clips/:id/edit" element={<EditClip />} />
|
||||||
<Route path="/my-vods" element={<MyVods />} />
|
<Route path="/vods/:id/edit" element={<EditVod />} />
|
||||||
<Route path="/video/:id" element={<MediaPlayer noun="Clip" getMediaUrl={getClipMediaUrl} fetchDetails={getClipById} />} />
|
<Route path="/profile" element={<Profile />} />
|
||||||
<Route path="/vod/:id" element={<MediaPlayer noun="VoD" getMediaUrl={getVodMediaUrl} fetchDetails={getVodById} />} />
|
<Route path="/stream" element={<StreamControl />} />
|
||||||
<Route path="/clips/:id/edit" element={<EditClip />} />
|
</Route>
|
||||||
<Route path="/vods/:id/edit" element={<EditVod />} />
|
</Routes>
|
||||||
<Route path="/profile" element={<Profile />} />
|
</Router>
|
||||||
<Route path="/stream" element={<StreamControl />} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="*" element={<NotFound />} />
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
|
||||||
</Router>
|
|
||||||
</AuthProvider>
|
|
||||||
</ThemeProvider>
|
|
||||||
</ GoogleOAuthProvider>
|
</ GoogleOAuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,51 +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";
|
|
||||||
import {registerUnauthorizedHandler} from "../utils/api/client.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();
|
|
||||||
await refresh();
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
registerUnauthorizedHandler(() => setUser(null));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthContext.Provider value={{ user, loading, login, logout, refresh }}>
|
|
||||||
{children}
|
|
||||||
</AuthContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AuthProvider;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
|
||||||
import { useAuth } from "./useAuth";
|
|
||||||
|
|
||||||
const RequireAuth = () => {
|
|
||||||
const { user, loading } = useAuth();
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
if (loading) return null;
|
|
||||||
if (!user) return <Navigate to="/" replace state={{ from: location }} />;
|
|
||||||
|
|
||||||
return <Outlet />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RequireAuth;
|
|
||||||
@@ -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,18 +0,0 @@
|
|||||||
import type { ElementType } from "react";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
icon: ElementType;
|
|
||||||
title: string;
|
|
||||||
accent?: "primary" | "accent";
|
|
||||||
};
|
|
||||||
|
|
||||||
const CardHeader = ({ icon: Icon, title, accent = "primary" }: Props) => (
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${accent === "primary" ? "bg-terracotta/10" : "bg-olive/10"}`}>
|
|
||||||
<Icon size={16} className={accent === "primary" ? "text-terracotta" : "text-olive"} />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-heading text-text-primary">{title}</h2>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default CardHeader;
|
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
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="w-full my-1.5 mx-2 border-hover" />;
|
||||||
|
|
||||||
const Sidebar = ({className}: props) => {
|
const Sidebar = ({user, className}: props) => {
|
||||||
const { user } = useAuth();
|
|
||||||
|
|
||||||
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-sidebar border-r border-hairline p-2 items-center gap-1", className)}>
|
||||||
<h2 className={"font-heading text-2xl mt-1"}>The VoD System</h2>
|
<h2 className={"font-heading text-2xl mt-1"}>The VoD System</h2>
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import { Menu, X, Sun, Moon } 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";
|
|
||||||
import { useTheme } from "../theme/useTheme.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;
|
||||||
}
|
}
|
||||||
@@ -21,12 +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 { theme, toggleTheme } = useTheme();
|
|
||||||
|
|
||||||
const handleLogin = (response: CredentialResponse) => {
|
const handleLogin = (response: CredentialResponse) => {
|
||||||
if (!response.credential) {
|
if (!response.credential) {
|
||||||
@@ -35,16 +37,21 @@ 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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const themeLabel = theme === "dark" ? "Switch to light mode" : "Switch to dark mode";
|
|
||||||
|
|
||||||
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);
|
||||||
});
|
});
|
||||||
@@ -56,16 +63,7 @@ const Topbar = ({
|
|||||||
{sidebarToggled ? <Menu size={24}/> : <X size={24}/>}
|
{sidebarToggled ? <Menu size={24}/> : <X size={24}/>}
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
{ user ? (
|
||||||
<MenuButton
|
|
||||||
onClick={toggleTheme}
|
|
||||||
title={themeLabel}
|
|
||||||
aria-label={themeLabel}
|
|
||||||
>
|
|
||||||
{theme === "dark" ? <Sun size={20}/> : <Moon size={20}/>}
|
|
||||||
</MenuButton>
|
|
||||||
|
|
||||||
{ user ? (
|
|
||||||
<div className={"hover:bg-hover rounded-lg p-0.5"}>
|
<div className={"hover:bg-hover rounded-lg p-0.5"}>
|
||||||
<img
|
<img
|
||||||
className={"w-8 h-8 rounded-full inline-block"}
|
className={"w-8 h-8 rounded-full inline-block"}
|
||||||
@@ -91,15 +89,13 @@ const Topbar = ({
|
|||||||
className={"text-error font-medium"} />
|
className={"text-error font-medium"} />
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
) :
|
) :
|
||||||
(
|
(
|
||||||
<GoogleLogin
|
<GoogleLogin
|
||||||
shape={"pill"}
|
shape={"pill"}
|
||||||
theme={theme === "dark" ? "filled_black" : "outline"}
|
|
||||||
useOneTap={false}
|
useOneTap={false}
|
||||||
onSuccess={(credentialResponse) => handleLogin(credentialResponse)} />
|
onSuccess={(credentialResponse) => handleLogin(credentialResponse)} />
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,173 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Bookmark, Plus, X } from "lucide-react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import Box from "../Box.tsx";
|
|
||||||
import CardHeader from "../CardHeader.tsx";
|
|
||||||
import PrimaryButton from "../buttons/PrimaryButton.tsx";
|
|
||||||
import type { Marker } from "../../utils/types.ts";
|
|
||||||
import { formatLocalTime } 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";
|
|
||||||
|
|
||||||
type RailSegment = "none" | "plain" | "lit";
|
|
||||||
|
|
||||||
const railClass = (segment: RailSegment) => clsx(
|
|
||||||
"w-px",
|
|
||||||
segment === "lit" && "bg-terracotta",
|
|
||||||
segment === "plain" && "bg-inactive-strong/60",
|
|
||||||
segment === "none" && "bg-transparent"
|
|
||||||
);
|
|
||||||
|
|
||||||
type MarkerRowProps = {
|
|
||||||
marker: Marker;
|
|
||||||
role: "start" | "end" | "between" | null;
|
|
||||||
/** Rail segments above and below the dot — "lit" inside the chosen section. */
|
|
||||||
railAbove: RailSegment;
|
|
||||||
railBelow: RailSegment;
|
|
||||||
onSelect: () => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MarkerRow = ({ marker, role, railAbove, railBelow, onSelect, onDelete }: MarkerRowProps) => (
|
|
||||||
<li className="group relative flex gap-3">
|
|
||||||
{/* Timeline rail: a dot per marker, with the segment between the chosen
|
|
||||||
start and end lit so the selected section reads as one run. */}
|
|
||||||
<div className="flex w-2.5 flex-col items-center flex-shrink-0">
|
|
||||||
<span className={clsx("h-3.5 flex-shrink-0", railClass(railAbove))} />
|
|
||||||
<span className={clsx(
|
|
||||||
"h-2.5 w-2.5 rounded-full border-2 flex-shrink-0 transition-colors duration-150",
|
|
||||||
role === "start" && "border-terracotta bg-terracotta",
|
|
||||||
role === "end" && "border-olive bg-olive",
|
|
||||||
role === "between" && "border-terracotta bg-card",
|
|
||||||
role === null && "border-inactive-strong bg-card"
|
|
||||||
)} />
|
|
||||||
<span className={clsx("flex-1", railClass(railBelow))} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* The rail spans the full row, so the breathing room goes on the button —
|
|
||||||
the line stays continuous while neighbouring rings no longer touch. */}
|
|
||||||
<button
|
|
||||||
onClick={onSelect}
|
|
||||||
className={clsx(
|
|
||||||
"min-w-0 flex-1 rounded-md my-0.5 py-2 pl-2.5 pr-7 text-left transition-colors duration-150",
|
|
||||||
role === "start" && "bg-selected ring-1 ring-terracotta",
|
|
||||||
role === "end" && "bg-selected ring-1 ring-olive",
|
|
||||||
(role === "between" || role === null) && "hover:bg-hover"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p className="truncate text-sm font-medium text-text-strong">{marker.message}</p>
|
|
||||||
<p className="font-data text-[11px] text-muted tabular-nums">
|
|
||||||
{formatLocalTime(marker.timestamp)}
|
|
||||||
{role === "start" && <span className="font-medium text-terracotta"> · start</span>}
|
|
||||||
{role === "end" && <span className="font-medium text-olive"> · end</span>}
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={onDelete}
|
|
||||||
title="Delete marker"
|
|
||||||
aria-label={`Delete marker ${marker.message}`}
|
|
||||||
className="absolute right-1 top-2.5 flex h-5 w-5 items-center justify-center rounded-md text-muted opacity-0 transition duration-150 hover:bg-hover hover:text-error focus-visible:opacity-100 group-hover:opacity-100"
|
|
||||||
>
|
|
||||||
<X size={12} />
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
/** Newest first — the list renders them in the order given. */
|
|
||||||
markers: Marker[];
|
|
||||||
startMarkerId: string;
|
|
||||||
endMarkerId: string;
|
|
||||||
error: string | null;
|
|
||||||
/** Resolves true once the marker exists, so the input only clears on success. */
|
|
||||||
onAdd: (message: string) => Promise<boolean>;
|
|
||||||
onSelect: (id: number) => void;
|
|
||||||
onDelete: (id: number) => void;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MarkerList = ({ markers, startMarkerId, endMarkerId, error, onAdd, onSelect, onDelete, className }: Props) => {
|
|
||||||
const [message, setMessage] = useState("");
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
|
|
||||||
const handleAdd = async () => {
|
|
||||||
if (!message.trim() || busy) return;
|
|
||||||
setBusy(true);
|
|
||||||
if (await onAdd(message.trim())) setMessage("");
|
|
||||||
setBusy(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Either of the two may have been picked first, so the lit run is bounded by
|
|
||||||
// whichever indices come first/last rather than by start/end.
|
|
||||||
const startIndex = markers.findIndex((m) => String(m.id) === startMarkerId);
|
|
||||||
const endIndex = markers.findIndex((m) => String(m.id) === endMarkerId);
|
|
||||||
const hasRange = startIndex !== -1 && endIndex !== -1;
|
|
||||||
const rangeFrom = hasRange ? Math.min(startIndex, endIndex) : -1;
|
|
||||||
const rangeTo = hasRange ? Math.max(startIndex, endIndex) : -1;
|
|
||||||
// The rail segment sitting between marker `i` and the one after it.
|
|
||||||
const railSegment = (i: number): RailSegment => {
|
|
||||||
if (i < 0 || i >= markers.length - 1) return "none";
|
|
||||||
return hasRange && i >= rangeFrom && i < rangeTo ? "lit" : "plain";
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box className={clsx("p-5 flex flex-col gap-4", className)}>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<CardHeader icon={Bookmark} title="Markers" accent="accent" />
|
|
||||||
{markers.length > 0 && (
|
|
||||||
<span className="font-data text-[11px] text-muted tabular-nums">{markers.length}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={message}
|
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
|
||||||
placeholder="What just happened?"
|
|
||||||
className={inputClass}
|
|
||||||
/>
|
|
||||||
<PrimaryButton
|
|
||||||
onClick={handleAdd}
|
|
||||||
disabled={busy || !message.trim()}
|
|
||||||
className="flex items-center gap-1.5 px-3 whitespace-nowrap"
|
|
||||||
>
|
|
||||||
<Plus size={15} />
|
|
||||||
Add
|
|
||||||
</PrimaryButton>
|
|
||||||
</div>
|
|
||||||
{error && <p className="text-sm text-error">{error}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{markers.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted">
|
|
||||||
Nothing marked yet. Markers stack up here, newest first — pick two to save the section between them.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
/* py-0.5 keeps the scroll container from clipping the selection
|
|
||||||
ring on the first and last rows. */
|
|
||||||
<ul className="-mr-1 flex max-h-[26rem] flex-col overflow-y-auto py-0.5 pr-1">
|
|
||||||
{markers.map((m, i) => (
|
|
||||||
<MarkerRow
|
|
||||||
key={m.id}
|
|
||||||
marker={m}
|
|
||||||
role={String(m.id) === startMarkerId ? "start"
|
|
||||||
: String(m.id) === endMarkerId ? "end"
|
|
||||||
: hasRange && i > rangeFrom && i < rangeTo ? "between"
|
|
||||||
: null}
|
|
||||||
railAbove={railSegment(i - 1)}
|
|
||||||
railBelow={railSegment(i)}
|
|
||||||
onSelect={() => onSelect(m.id)}
|
|
||||||
onDelete={() => onDelete(m.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MarkerList;
|
|
||||||
@@ -9,12 +9,9 @@ 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-body text-text-strong font-bold 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-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors"
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
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";
|
import {config} from "../../config.ts";
|
||||||
|
|
||||||
@@ -18,12 +19,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 = config.apiUrl;
|
||||||
|
|
||||||
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 +37,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 +66,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-media-backdrop/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-text-primary/75 text-on-dark px-1.5 py-0.5 rounded text-xs font-data pointer-events-none">
|
||||||
{formatTime(duration)}
|
{formatTime(duration)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -77,7 +83,7 @@ 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-media-backdrop/75 hover:bg-media-backdrop/90 text-on-dark rounded p-0.5 transition-colors"
|
className="bg-text-primary/75 hover:bg-text-primary/90 text-on-dark rounded p-0.5 transition-colors"
|
||||||
>
|
>
|
||||||
<MoreVertical size={14} />
|
<MoreVertical size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
/* Drive the `dark:` variant off the same data-theme attribute the toggle sets,
|
|
||||||
instead of Tailwind's default prefers-color-scheme. Without this a stray
|
|
||||||
`dark:` utility would disagree with an explicit light/dark choice. */
|
|
||||||
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
/* ── Fonts ───────────────────────────────────────────────────────────── */
|
/* ── Fonts ───────────────────────────────────────────────────────────── */
|
||||||
--font-body: "IBM Plex Sans", system-ui, sans-serif;
|
--font-body: "IBM Plex Sans", system-ui, sans-serif;
|
||||||
@@ -17,17 +12,12 @@
|
|||||||
--color-fields: #F4F0E8;
|
--color-fields: #F4F0E8;
|
||||||
--color-dropzone: #F9F6EF;
|
--color-dropzone: #F9F6EF;
|
||||||
|
|
||||||
/* Letterbox behind video. Dark in both themes — it is a cinema surface,
|
|
||||||
not an ink colour — so `text-on-dark` always reads on top of it. */
|
|
||||||
--color-media-backdrop: #2B2620;
|
|
||||||
|
|
||||||
/* ── Ink ─────────────────────────────────────────────────────────────── */
|
/* ── Ink ─────────────────────────────────────────────────────────────── */
|
||||||
--color-text-primary: #2B2620;
|
--color-text-primary: #2B2620;
|
||||||
--color-text-strong: #35302A;
|
--color-text-strong: #35302A;
|
||||||
--color-text-body: #554D44;
|
--color-text-body: #554D44;
|
||||||
--color-text-secondary: #6E6559;
|
--color-text-secondary: #6E6559;
|
||||||
--color-muted: #9A9084;
|
--color-muted: #9A9084;
|
||||||
--color-faint-mono: #A79C8E;
|
|
||||||
|
|
||||||
/* ── Accents ─────────────────────────────────────────────────────────── */
|
/* ── Accents ─────────────────────────────────────────────────────────── */
|
||||||
--color-terracotta: #A85A38;
|
--color-terracotta: #A85A38;
|
||||||
@@ -53,55 +43,6 @@
|
|||||||
--color-inactive-strong: #C4BAA9;
|
--color-inactive-strong: #C4BAA9;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode. Only token values change here: every component already styles
|
|
||||||
itself with the semantic utilities above, so the whole UI follows from this
|
|
||||||
one block. `--color-on-dark` is deliberately absent — it is the ink on the
|
|
||||||
media backdrop, which stays dark in both themes. */
|
|
||||||
:root[data-theme="dark"] {
|
|
||||||
color-scheme: dark;
|
|
||||||
|
|
||||||
/* ── Surfaces ── near-black canvas, neutral cards lifting off it ─────── */
|
|
||||||
--color-background: #0E0E10;
|
|
||||||
--color-sidebar: #09090B;
|
|
||||||
--color-card: #17171A;
|
|
||||||
--color-fields: #121215;
|
|
||||||
--color-dropzone: #141417;
|
|
||||||
|
|
||||||
--color-media-backdrop: #000000;
|
|
||||||
|
|
||||||
/* ── Ink ─────────────────────────────────────────────────────────────── */
|
|
||||||
--color-text-primary: #F4F4F5;
|
|
||||||
--color-text-strong: #E3E3E7;
|
|
||||||
--color-text-body: #BFBFC6;
|
|
||||||
--color-text-secondary: #94949D;
|
|
||||||
--color-muted: #76767F;
|
|
||||||
--color-faint-mono: #6A6A73;
|
|
||||||
|
|
||||||
/* ── Accents ── brightened to carry on a dark canvas, so hover now lifts
|
|
||||||
rather than deepens ─────────────────────────────────────────────────── */
|
|
||||||
--color-terracotta: #E07A4C;
|
|
||||||
--color-terracotta-hover: #F08F62;
|
|
||||||
--color-terracotta-light: #F0A177;
|
|
||||||
--color-terracotta-disabled: #5E463A;
|
|
||||||
--color-olive: #A3B678;
|
|
||||||
|
|
||||||
/* ── Status ──────────────────────────────────────────────────────────── */
|
|
||||||
--color-live: #F2604A;
|
|
||||||
--color-error: #F2604A;
|
|
||||||
|
|
||||||
/* ── Ink on accent surfaces ── the accents are light here, so the ink that
|
|
||||||
sits on them flips dark ─────────────────────────────────────────────── */
|
|
||||||
--color-on-accent: #17100B;
|
|
||||||
|
|
||||||
/* ── Lines, chips, placeholders ──────────────────────────────────────── */
|
|
||||||
--color-hairline: #232327;
|
|
||||||
--color-hover: #26262B;
|
|
||||||
--color-selected: #2A1F18;
|
|
||||||
--color-ring: #33333A;
|
|
||||||
--color-inactive: #2C2C32;
|
|
||||||
--color-inactive-strong: #55555E;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-family: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
font-family: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@@ -110,7 +51,6 @@
|
|||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
color-scheme: light;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -125,16 +65,6 @@ body {
|
|||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Google Identity Services renders its sign-in button in a cross-origin iframe
|
|
||||||
whose document is light-scheme with a transparent background. Left to inherit
|
|
||||||
our `color-scheme: dark`, Chrome cannot honour dark there and paints an opaque
|
|
||||||
white canvas behind it instead — framing the pill in a white box on every dark
|
|
||||||
page. Pinning the iframe back to light keeps it transparent; the button's own
|
|
||||||
colours come from the `theme` prop in Topbar.tsx. */
|
|
||||||
iframe[src*="accounts.google.com/gsi/"] {
|
|
||||||
color-scheme: light;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Clip trim slider (react-range-slider-input). Its own stylesheet ships blue
|
/* Clip trim slider (react-range-slider-input). Its own stylesheet ships blue
|
||||||
defaults; the #id beats those class selectors regardless of import order. */
|
defaults; the #id beats those class selectors regardless of import order. */
|
||||||
#range-slider {
|
#range-slider {
|
||||||
@@ -156,4 +86,4 @@ iframe[src*="accounts.google.com/gsi/"] {
|
|||||||
|
|
||||||
#range-slider .range-slider__thumb:focus-visible {
|
#range-slider .range-slider__thumb:focus-visible {
|
||||||
box-shadow: 0 0 0 4px color-mix(in oklab, var(--color-terracotta) 35%, transparent);
|
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;
|
||||||
@@ -22,11 +36,14 @@ const MainLayout = () => {
|
|||||||
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-[252px_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 />
|
||||||
@@ -34,4 +51,4 @@ const MainLayout = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
export default MainLayout;
|
export default MainLayout;
|
||||||
@@ -6,10 +6,13 @@ import ClipRangeSlider from "./../components/video/ClipRangeSlider";
|
|||||||
import ConfigBox from "../components/video/ConfigBox.tsx";
|
import ConfigBox from "../components/video/ConfigBox.tsx";
|
||||||
import ExportWidget from "../components/video/ExportWidget.tsx";
|
import ExportWidget from "../components/video/ExportWidget.tsx";
|
||||||
import { compress } from "../utils/api/media"
|
import { compress } from "../utils/api/media"
|
||||||
import { getJob, downloadJob } from "../utils/api/jobs"
|
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 ClipEdit = () => {
|
const ClipEdit = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -61,18 +64,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'}`);
|
||||||
@@ -108,14 +105,14 @@ const ClipEdit = () => {
|
|||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
if (!uploadedId) return;
|
if (!uploadedId) return;
|
||||||
|
|
||||||
let blob: Blob;
|
const response = await fetch(API_URL + `/api/v1/jobs/${uploadedId}/download`, { credentials: 'include' });
|
||||||
try {
|
|
||||||
blob = await downloadJob(uploadedId);
|
if (!response.ok) {
|
||||||
} catch (err) {
|
console.error('Download failed');
|
||||||
console.error('Download failed', err);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
|
|
||||||
@@ -132,7 +129,7 @@ const ClipEdit = () => {
|
|||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={videoUrl}
|
src={videoUrl}
|
||||||
className={"w-full rounded-lg shadow-sm bg-media-backdrop"}
|
className={"w-full rounded-lg shadow-sm bg-text-primary"}
|
||||||
onLoadedMetadata={handleVideoMetadataLoaded}
|
onLoadedMetadata={handleVideoMetadataLoaded}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
{/* Player */}
|
{/* Player */}
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="relative bg-media-backdrop rounded-xl overflow-hidden flex-1 min-h-0 select-none"
|
className="relative bg-text-primary 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}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Radio, Check, 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 CardHeader from "../components/CardHeader.tsx";
|
|
||||||
import MarkerList from "../components/stream/MarkerList.tsx";
|
|
||||||
import PrimaryButton from "../components/buttons/PrimaryButton.tsx";
|
import PrimaryButton from "../components/buttons/PrimaryButton.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 { 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-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors";
|
||||||
const labelClass = "font-data text-[11px] text-muted uppercase tracking-[0.12em]";
|
const labelClass = "font-data text-[11px] text-muted uppercase tracking-[0.12em]";
|
||||||
@@ -24,6 +22,15 @@ type TrackedJob = {
|
|||||||
errorOutput: string | null;
|
errorOutput: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CardHeader = ({ icon: Icon, title, accent = "primary" }: { icon: React.ElementType; title: string; accent?: "primary" | "accent" }) => (
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className={clsx("w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0", accent === "primary" ? "bg-terracotta/10" : "bg-olive/10")}>
|
||||||
|
<Icon size={16} className={accent === "primary" ? "text-terracotta" : "text-olive"} />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-heading text-text-primary">{title}</h2>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const JobStatus = ({ job }: { job: TrackedJob }) => {
|
const JobStatus = ({ job }: { job: TrackedJob }) => {
|
||||||
if (job.state === 'FAILED') {
|
if (job.state === 'FAILED') {
|
||||||
return (
|
return (
|
||||||
@@ -53,11 +60,14 @@ const JobStatus = ({ job }: { job: TrackedJob }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
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 [markers, setMarkers] = useState<Marker[]>([]);
|
const [markers, setMarkers] = useState<Marker[]>([]);
|
||||||
|
const [markerMessage, setMarkerMessage] = useState("");
|
||||||
|
const [markerBusy, setMarkerBusy] = useState(false);
|
||||||
const [markerError, setMarkerError] = useState<string | null>(null);
|
const [markerError, setMarkerError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [clipDuration, setClipDuration] = useState(30);
|
const [clipDuration, setClipDuration] = useState(30);
|
||||||
@@ -75,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(() => {});
|
||||||
@@ -93,10 +107,16 @@ const StreamControl = () => {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [user, streamStatus?.id]);
|
}, [user, streamStatus?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!streamDetails?.startDate) return;
|
||||||
|
const interval = setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [streamDetails?.startDate]);
|
||||||
|
|
||||||
const refreshMarkers = () => {
|
const refreshMarkers = () => {
|
||||||
getMarkers()
|
getMarkers()
|
||||||
.then((data) => setMarkers(data
|
.then((data) => setMarkers(data
|
||||||
.sort((a, b) => stringToDate(b.timestamp).getTime() - stringToDate(a.timestamp).getTime())))
|
.sort((a, b) => stringToDate(a.timestamp).getTime() - stringToDate(b.timestamp).getTime())))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -133,15 +153,18 @@ const StreamControl = () => {
|
|||||||
setJobs((prev) => [{ uuid, label, progress: 0, state: 'READY', errorOutput: null }, ...prev]);
|
setJobs((prev) => [{ uuid, label, progress: 0, state: 'READY', errorOutput: null }, ...prev]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddMarker = async (message: string) => {
|
const handleAddMarker = async () => {
|
||||||
|
if (!markerMessage.trim() || markerBusy) return;
|
||||||
|
setMarkerBusy(true);
|
||||||
setMarkerError(null);
|
setMarkerError(null);
|
||||||
try {
|
try {
|
||||||
await createMarker(message);
|
await createMarker(markerMessage.trim());
|
||||||
|
setMarkerMessage("");
|
||||||
refreshMarkers();
|
refreshMarkers();
|
||||||
return true;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMarkerError(err instanceof Error ? err.message : "Failed to add marker");
|
setMarkerError(err instanceof Error ? err.message : "Failed to add marker");
|
||||||
return false;
|
} finally {
|
||||||
|
setMarkerBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -160,7 +183,7 @@ const StreamControl = () => {
|
|||||||
const handleSaveByMarkers = async () => {
|
const handleSaveByMarkers = async () => {
|
||||||
setMarkerSaveError(null);
|
setMarkerSaveError(null);
|
||||||
if (!startMarkerId || !endMarkerId) {
|
if (!startMarkerId || !endMarkerId) {
|
||||||
setMarkerSaveError("Pick a start and an end marker from the timeline");
|
setMarkerSaveError("Tap two markers above to pick a start and end");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -191,7 +214,7 @@ const StreamControl = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectMarker = (id: number) => {
|
const handleChipClick = (id: number) => {
|
||||||
const idStr = String(id);
|
const idStr = String(id);
|
||||||
setMarkerSaveError(null);
|
setMarkerSaveError(null);
|
||||||
if (startMarkerId === idStr) { setStartMarkerId(""); return; }
|
if (startMarkerId === idStr) { setStartMarkerId(""); return; }
|
||||||
@@ -209,6 +232,10 @@ const StreamControl = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const elapsed = streamDetails?.startDate
|
||||||
|
? formatTime((now - stringToDate(streamDetails.startDate).getTime()) / 1000)
|
||||||
|
: null;
|
||||||
|
|
||||||
const startMarker = markers.find((m) => String(m.id) === startMarkerId);
|
const startMarker = markers.find((m) => String(m.id) === startMarkerId);
|
||||||
const endMarker = markers.find((m) => String(m.id) === endMarkerId);
|
const endMarker = markers.find((m) => String(m.id) === endMarkerId);
|
||||||
|
|
||||||
@@ -236,30 +263,97 @@ const StreamControl = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Radio size={28} className={isStreaming ? "text-live" : "text-inactive"} />
|
{isStreaming && elapsed ? (
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-2xl font-data text-text-primary tabular-nums">{elapsed}</p>
|
||||||
|
<p className={labelClass}>elapsed</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Radio size={28} className="text-inactive" />
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{isStreaming && (
|
{isStreaming && (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_20rem] gap-5 items-start">
|
<>
|
||||||
{/* Marker timeline. First on narrow screens, where marking
|
{/* Quick mark toolbar */}
|
||||||
something as it happens beats the action forms for urgency. */}
|
<Box className="p-4 flex flex-col gap-3">
|
||||||
<MarkerList
|
<div className="flex items-center gap-3">
|
||||||
className="lg:order-last lg:sticky lg:top-6"
|
<Bookmark size={18} className="text-olive flex-shrink-0" />
|
||||||
markers={markers}
|
<input
|
||||||
startMarkerId={startMarkerId}
|
type="text"
|
||||||
endMarkerId={endMarkerId}
|
value={markerMessage}
|
||||||
error={markerError}
|
onChange={(e) => setMarkerMessage(e.target.value)}
|
||||||
onAdd={handleAddMarker}
|
onKeyDown={(e) => e.key === "Enter" && handleAddMarker()}
|
||||||
onSelect={handleSelectMarker}
|
placeholder="What just happened?"
|
||||||
onDelete={handleDeleteMarker}
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
|
<PrimaryButton onClick={handleAddMarker} disabled={markerBusy || !markerMessage.trim()} className="whitespace-nowrap">
|
||||||
|
Add Marker
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
{markerError && <p className="text-sm text-error">{markerError}</p>}
|
||||||
|
|
||||||
|
{markers.length > 0 && (
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-0.5 -mx-1 px-1 pt-1.5">
|
||||||
|
{markers.map((m) => {
|
||||||
|
const isStart = startMarkerId === String(m.id);
|
||||||
|
const isEnd = endMarkerId === String(m.id);
|
||||||
|
return (
|
||||||
|
<div key={m.id} className="group relative flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => handleChipClick(m.id)}
|
||||||
|
className={clsx(
|
||||||
|
"flex flex-col items-start gap-0.5 rounded-lg border px-3 py-1.5 text-left transition-colors duration-150",
|
||||||
|
isStart && "border-terracotta bg-selected ring-1 ring-terracotta",
|
||||||
|
isEnd && "border-olive bg-selected ring-1 ring-olive",
|
||||||
|
!isStart && !isEnd && "border-hairline hover:border-ring hover:bg-hover"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-medium text-text-strong max-w-40 truncate">{m.message}</span>
|
||||||
|
<span className="font-data text-[11px] text-muted">
|
||||||
|
{formatLocalDate(m.timestamp, { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
|
||||||
|
{isStart && <span className="text-terracotta font-medium"> · start</span>}
|
||||||
|
{isEnd && <span className="text-olive font-medium"> · end</span>}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleDeleteMarker(m.id); }}
|
||||||
|
title="Delete marker"
|
||||||
|
className="hidden group-hover:flex items-center justify-center absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-muted hover:bg-error text-on-accent transition-colors duration-150"
|
||||||
|
>
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Forefront actions */}
|
{/* Forefront actions */}
|
||||||
<div className="flex flex-col gap-5">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5 items-stretch">
|
||||||
|
<Box className="p-6 flex flex-col gap-3">
|
||||||
|
<CardHeader icon={Scissors} title="Clip the last few seconds" />
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className={labelClass}>Duration (seconds)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={clipDuration}
|
||||||
|
onChange={(e) => setClipDuration(Number(e.target.value))}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input type="text" placeholder="Title" value={clipTitle} onChange={(e) => setClipTitle(e.target.value)} className={inputClass} />
|
||||||
|
<textarea placeholder="Description" value={clipDescription} onChange={(e) => setClipDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} />
|
||||||
|
{clipError && <p className="text-sm text-error">{clipError}</p>}
|
||||||
|
<PrimaryButton onClick={handleClip} className="self-start mt-auto">Clip</PrimaryButton>
|
||||||
|
</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 to save a section between them.</p>
|
<p className="text-sm text-muted">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">
|
||||||
@@ -284,37 +378,19 @@ const StreamControl = () => {
|
|||||||
)}
|
)}
|
||||||
<PrimaryButton onClick={handleSaveByMarkers} disabled={markers.length < 2} className="self-start mt-auto">Save</PrimaryButton>
|
<PrimaryButton onClick={handleSaveByMarkers} disabled={markers.length < 2} className="self-start mt-auto">Save</PrimaryButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box className="p-6 flex flex-col gap-3">
|
|
||||||
<CardHeader icon={Scissors} title="Clip the last few seconds" />
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label className={labelClass}>Duration (seconds)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={clipDuration}
|
|
||||||
onChange={(e) => setClipDuration(Number(e.target.value))}
|
|
||||||
className={inputClass}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<input type="text" placeholder="Title" value={clipTitle} onChange={(e) => setClipTitle(e.target.value)} className={inputClass} />
|
|
||||||
<textarea placeholder="Description" value={clipDescription} onChange={(e) => setClipDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} />
|
|
||||||
{clipError && <p className="text-sm text-error">{clipError}</p>}
|
|
||||||
<PrimaryButton onClick={handleClip} className="self-start mt-auto">Clip</PrimaryButton>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box className="p-5 flex flex-col gap-3">
|
|
||||||
<CardHeader icon={Activity} title="Recent actions" />
|
|
||||||
{jobs.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted">Clip and save actions will show up here.</p>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{jobs.map((job) => <JobStatus key={job.uuid} job={job} />)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<Box className="p-5 flex flex-col gap-3">
|
||||||
|
<CardHeader icon={Activity} title="Recent actions" />
|
||||||
|
{jobs.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted">Clip and save actions will show up here.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{jobs.map((job) => <JobStatus key={job.uuid} job={job} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import { createContext } from "react";
|
|
||||||
|
|
||||||
/** What the user chose. "system" defers to the OS setting. */
|
|
||||||
type ThemePreference = "light" | "dark" | "system";
|
|
||||||
|
|
||||||
/** The theme actually on screen, once "system" has been resolved. */
|
|
||||||
type ResolvedTheme = "light" | "dark";
|
|
||||||
|
|
||||||
type ThemeContextValue = {
|
|
||||||
/** The stored choice, which may be "system". */
|
|
||||||
preference: ThemePreference;
|
|
||||||
/** The theme currently applied to the document. */
|
|
||||||
theme: ResolvedTheme;
|
|
||||||
/** Store a new preference and apply it immediately. */
|
|
||||||
setPreference: (preference: ThemePreference) => void;
|
|
||||||
/** Flip between light and dark, pinning the result as an explicit choice. */
|
|
||||||
toggleTheme: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
|
||||||
|
|
||||||
export { ThemeContext };
|
|
||||||
export type { ThemeContextValue, ThemePreference, ResolvedTheme };
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { ThemeContext } from "./ThemeContext.ts";
|
|
||||||
import type { ResolvedTheme, ThemePreference } from "./ThemeContext.ts";
|
|
||||||
|
|
||||||
/** Shared with the pre-paint script in index.html — keep the two in step. */
|
|
||||||
const STORAGE_KEY = "theme";
|
|
||||||
const DARK_QUERY = "(prefers-color-scheme: dark)";
|
|
||||||
|
|
||||||
const readPreference = (): ThemePreference => {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
|
||||||
if (stored === "light" || stored === "dark" || stored === "system") {
|
|
||||||
return stored;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Storage blocked (private browsing, cookies disabled) — follow the OS.
|
|
||||||
}
|
|
||||||
return "system";
|
|
||||||
};
|
|
||||||
|
|
||||||
const readSystemTheme = (): ResolvedTheme =>
|
|
||||||
window.matchMedia(DARK_QUERY).matches ? "dark" : "light";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Owns the single source of truth for the active theme. Nothing reads colours
|
|
||||||
* from here: the palette lives in index.css and switches on the `data-theme`
|
|
||||||
* attribute this provider writes, so components keep using the semantic
|
|
||||||
* utilities (`bg-card`, `text-text-primary`) and follow along for free.
|
|
||||||
*/
|
|
||||||
const ThemeProvider = ({ children }: { children: ReactNode }) => {
|
|
||||||
const [preference, setPreferenceState] = useState<ThemePreference>(readPreference);
|
|
||||||
const [systemTheme, setSystemTheme] = useState<ResolvedTheme>(readSystemTheme);
|
|
||||||
|
|
||||||
// Keep following the OS while the preference is "system".
|
|
||||||
useEffect(() => {
|
|
||||||
const query = window.matchMedia(DARK_QUERY);
|
|
||||||
const onChange = (event: MediaQueryListEvent) =>
|
|
||||||
setSystemTheme(event.matches ? "dark" : "light");
|
|
||||||
|
|
||||||
query.addEventListener("change", onChange);
|
|
||||||
return () => query.removeEventListener("change", onChange);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const theme: ResolvedTheme = preference === "system" ? systemTheme : preference;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.documentElement.dataset.theme = theme;
|
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
const setPreference = useCallback((next: ThemePreference) => {
|
|
||||||
setPreferenceState(next);
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEY, next);
|
|
||||||
} catch {
|
|
||||||
// Storage blocked — the choice still applies for this session.
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleTheme = useCallback(() => {
|
|
||||||
setPreference(theme === "dark" ? "light" : "dark");
|
|
||||||
}, [theme, setPreference]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemeContext.Provider value={{ preference, theme, setPreference, toggleTheme }}>
|
|
||||||
{children}
|
|
||||||
</ThemeContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ThemeProvider;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { useContext } from "react";
|
|
||||||
import { ThemeContext } from "./ThemeContext.ts";
|
|
||||||
import type { ThemeContextValue } from "./ThemeContext.ts";
|
|
||||||
|
|
||||||
/** Access the centralised theme state. Must be used within a <ThemeProvider>. */
|
|
||||||
const useTheme = (): ThemeContextValue => {
|
|
||||||
const context = useContext(ThemeContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useTheme must be used within a ThemeProvider");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|
||||||
export { useTheme };
|
|
||||||
@@ -6,43 +6,9 @@ class AuthError extends Error {
|
|||||||
constructor() { super("Not authenticated"); this.name = "AuthError"; }
|
constructor() { super("Not authenticated"); this.name = "AuthError"; }
|
||||||
}
|
}
|
||||||
|
|
||||||
let onUnauthorized: (() => void) | null = null;
|
const isThumbnailAvailable = async (thumbnailUrl: string): Promise<boolean> => {
|
||||||
|
const response = await fetch(thumbnailUrl, { credentials: 'include' });
|
||||||
|
return response.ok;
|
||||||
|
};
|
||||||
|
|
||||||
export function registerUnauthorizedHandler(handler: () => void) {
|
export { API_URL, AuthError, isThumbnailAvailable };
|
||||||
onUnauthorized = handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Performs a request against the API, sending session cookies and funnelling
|
|
||||||
* expired/missing sessions through the registered unauthorized handler so the
|
|
||||||
* UI can drop back to a logged out state. Returns the raw response so callers
|
|
||||||
* that need something other than JSON (blobs, streams) can read it themselves.
|
|
||||||
*/
|
|
||||||
async function apiFetchRaw(url: string, options: RequestInit = {}): Promise<Response> {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
...options,
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.status == 401 || response.status == 403) {
|
|
||||||
onUnauthorized?.();
|
|
||||||
throw new AuthError();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
let message = text;
|
|
||||||
try { message = JSON.parse(text).message ?? text; } catch { /* not JSON, use raw text */ }
|
|
||||||
throw new Error(message || `Request failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function apiFetch<T>(url: string, options: RequestInit = {}): Promise<T> {
|
|
||||||
const response = await apiFetchRaw(url, options);
|
|
||||||
const text = await response.text();
|
|
||||||
return text ? JSON.parse(text) : (undefined as T);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { API_URL, AuthError, apiFetch, apiFetchRaw };
|
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
import type { APIResponse, Clip } from "../types.ts";
|
import type { APIResponse, Clip } from "../types.ts";
|
||||||
import { API_URL, apiFetch } from "./client.ts";
|
import { API_URL, AuthError } from "./client.ts";
|
||||||
|
|
||||||
const getClips = async (): Promise<Clip[]> => {
|
const getClips = async (): Promise<Clip[]> => {
|
||||||
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/clips');
|
const response = await fetch(API_URL + '/api/v1/clips', { credentials: 'include' });
|
||||||
if (result.status === 'error') throw new Error(`Failed to fetch clips: ${result.message}`);
|
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorResult: APIResponse = await response.json();
|
||||||
|
throw new Error(`Failed to fetch clips: ${errorResult.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
return result.data;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getClipById = async (id: string): Promise<Clip | null> => {
|
const getClipById = async (id: string): Promise<Clip | null> => {
|
||||||
const result = await apiFetch<APIResponse>(API_URL + `/api/v1/clips/${id}`);
|
const response = await fetch(API_URL + `/api/v1/clips/${id}`, { credentials: 'include' });
|
||||||
if (result.status === 'error') throw new Error(`Failed to fetch clip: ${result.message}`);
|
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401 || response.status === 403) throw new AuthError();
|
||||||
|
throw new Error(`Failed to fetch clip: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
return result.data;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -21,20 +31,31 @@ const getClipById = async (id: string): Promise<Clip | null> => {
|
|||||||
const getClipMediaUrl = (id: string): string => API_URL + `/api/v1/clips/${id}/media`;
|
const getClipMediaUrl = (id: string): string => API_URL + `/api/v1/clips/${id}/media`;
|
||||||
|
|
||||||
const patchClip = async (id: number, data: { title?: string; description?: string }): Promise<Clip> => {
|
const patchClip = async (id: number, data: { title?: string; description?: string }): Promise<Clip> => {
|
||||||
const result = await apiFetch<APIResponse>(API_URL + `/api/v1/clips/${id}`, {
|
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Failed to update clip: ${response.status}`);
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
if (result.status === 'error') throw new Error(`Failed to update clip: ${result.message}`);
|
if (result.status === 'error') throw new Error(`Failed to update clip: ${result.message}`);
|
||||||
|
|
||||||
return result.data;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteClip = async (id: number): Promise<void> => {
|
const deleteClip = async (id: number): Promise<void> => {
|
||||||
const result = await apiFetch<APIResponse>(API_URL + `/api/v1/clips/${id}`, { method: 'DELETE' });
|
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
|
||||||
if (result?.status === 'error') throw new Error(`Failed to delete clip: ${result.message}`);
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Failed to delete clip: ${response.status}`);
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
if (result.status === 'error') throw new Error(`Failed to delete clip: ${result.message}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export { getClips, getClipById, getClipMediaUrl, patchClip, deleteClip };
|
export { getClips, getClipById, getClipMediaUrl, patchClip, deleteClip };
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import type { APIResponse, JobResponse } from "../types.ts";
|
import type { APIResponse, JobResponse } from "../types.ts";
|
||||||
import { API_URL, apiFetch, apiFetchRaw } from "./client.ts";
|
import { API_URL } from "./client.ts";
|
||||||
|
|
||||||
const getJob = async (uuid: string): Promise<JobResponse> => {
|
const getJob = async (uuid: string): Promise<JobResponse> => {
|
||||||
const result = await apiFetch<APIResponse>(API_URL + `/api/v1/jobs/${uuid}`);
|
const response = await fetch(API_URL + `/api/v1/jobs/${uuid}`, { credentials: 'include' });
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Failed to fetch job: ${response.status}`);
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
if (result.status === 'error') throw new Error(`Failed to fetch job: ${result.message}`);
|
if (result.status === 'error') throw new Error(`Failed to fetch job: ${result.message}`);
|
||||||
|
|
||||||
return result.data;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadJob = async (uuid: string): Promise<Blob> => {
|
export { getJob };
|
||||||
const response = await apiFetchRaw(API_URL + `/api/v1/jobs/${uuid}/download`);
|
|
||||||
return response.blob();
|
|
||||||
};
|
|
||||||
|
|
||||||
export { getJob, downloadJob };
|
|
||||||
|
|||||||
@@ -1,28 +1,40 @@
|
|||||||
import type { APIResponse, Marker } from "../types.ts";
|
import type { APIResponse, Marker } from "../types.ts";
|
||||||
import { API_URL, apiFetch } from "./client.ts";
|
import { API_URL } from "./client.ts";
|
||||||
|
|
||||||
const getMarkers = async (): Promise<Marker[]> => {
|
const getMarkers = async (): Promise<Marker[]> => {
|
||||||
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/markers');
|
const response = await fetch(API_URL + '/api/v1/markers', { credentials: 'include' });
|
||||||
|
if (!response.ok) throw new Error(`Failed to fetch markers: ${response.status}`);
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
if (result.status === 'error') throw new Error(`Failed to fetch markers: ${result.message}`);
|
if (result.status === 'error') throw new Error(`Failed to fetch markers: ${result.message}`);
|
||||||
|
|
||||||
return result.data;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const createMarker = async (message: string): Promise<Marker> => {
|
const createMarker = async (message: string): Promise<Marker> => {
|
||||||
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/markers', {
|
const response = await fetch(API_URL + '/api/v1/markers', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify({ message }),
|
body: JSON.stringify({ message }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.status === 'error') throw new Error(`Failed to add marker: ${result.message}`);
|
const result: APIResponse = await response.json();
|
||||||
|
if (!response.ok || result.status === 'error') throw new Error(`Failed to add marker: ${result.message}`);
|
||||||
|
|
||||||
return result.data;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteMarker = async (id: number): Promise<void> => {
|
const deleteMarker = async (id: number): Promise<void> => {
|
||||||
const result = await apiFetch<APIResponse>(API_URL + `/api/v1/markers/${id}`, { method: 'DELETE' });
|
const response = await fetch(API_URL + `/api/v1/markers/${id}`, {
|
||||||
if (result?.status === 'error') throw new Error(`Failed to delete marker: ${result.message}`);
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Failed to delete marker: ${response.status}`);
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
if (result.status === 'error') throw new Error(`Failed to delete marker: ${result.message}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export { getMarkers, createMarker, deleteMarker };
|
export { getMarkers, createMarker, deleteMarker };
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { APIResponse, VideoMetadata } from "../types.ts";
|
import type { APIResponse, VideoMetadata } from "../types.ts";
|
||||||
import { API_URL, apiFetch } from "./client.ts";
|
import { API_URL } from "./client.ts";
|
||||||
|
|
||||||
const compress = async (file: File, options: VideoMetadata): Promise<string> => {
|
const compress = async (file: File, options: VideoMetadata): Promise<string> => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -13,48 +13,58 @@ const compress = async (file: File, options: VideoMetadata): Promise<string> =>
|
|||||||
if (options.height) formData.append('height', options.height.toString());
|
if (options.height) formData.append('height', options.height.toString());
|
||||||
if (options.fileSize) formData.append('fileSize', options.fileSize.toString());
|
if (options.fileSize) formData.append('fileSize', options.fileSize.toString());
|
||||||
|
|
||||||
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/media/compress', {
|
const response = await fetch(API_URL + '/api/v1/media/compress', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Compression failed: ${response.status}`);
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
if (result.status === 'error') throw new Error(`Compression failed: ${result.message}`);
|
if (result.status === 'error') throw new Error(`Compression failed: ${result.message}`);
|
||||||
|
|
||||||
return result.data.uuid;
|
return result.data.uuid;
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveSection = async (startTime: string, endTime: string, title?: string, description?: string): Promise<string> => {
|
const saveSection = async (startTime: string, endTime: string, title?: string, description?: string): Promise<string> => {
|
||||||
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/media/save', {
|
const response = await fetch(API_URL + '/api/v1/media/save', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify({ startTime, endTime, title, description }),
|
body: JSON.stringify({ startTime, endTime, title, description }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.status === 'error') throw new Error(`Failed to save section: ${result.message}`);
|
const result: APIResponse = await response.json();
|
||||||
|
if (!response.ok || result.status === 'error') throw new Error(`Failed to save section: ${result.message}`);
|
||||||
|
|
||||||
return result.data.uuid;
|
return result.data.uuid;
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveSectionByMarkers = async (startMarkerId: number, endMarkerId: number, title?: string, description?: string): Promise<string> => {
|
const saveSectionByMarkers = async (startMarkerId: number, endMarkerId: number, title?: string, description?: string): Promise<string> => {
|
||||||
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/media/save/markers', {
|
const response = await fetch(API_URL + '/api/v1/media/save/markers', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify({ startMarkerId, endMarkerId, title, description }),
|
body: JSON.stringify({ startMarkerId, endMarkerId, title, description }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.status === 'error') throw new Error(`Failed to save section: ${result.message}`);
|
const result: APIResponse = await response.json();
|
||||||
|
if (!response.ok || result.status === 'error') throw new Error(`Failed to save section: ${result.message}`);
|
||||||
|
|
||||||
return result.data.uuid;
|
return result.data.uuid;
|
||||||
};
|
};
|
||||||
|
|
||||||
const clipSection = async (duration: number, title?: string, description?: string): Promise<string> => {
|
const clipSection = async (duration: number, title?: string, description?: string): Promise<string> => {
|
||||||
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/media/clip', {
|
const response = await fetch(API_URL + '/api/v1/media/clip', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify({ duration, title, description }),
|
body: JSON.stringify({ duration, title, description }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.status === 'error') throw new Error(`Failed to clip section: ${result.message}`);
|
const result: APIResponse = await response.json();
|
||||||
|
if (!response.ok || result.status === 'error') throw new Error(`Failed to clip section: ${result.message}`);
|
||||||
|
|
||||||
return result.data.uuid;
|
return result.data.uuid;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,24 +1,28 @@
|
|||||||
import type { APIResponse, StreamHistoryItem, StreamStatus } from "../types.ts";
|
import type { APIResponse, StreamHistoryItem, StreamStatus } from "../types.ts";
|
||||||
import { API_URL, apiFetch } from "./client.ts";
|
import { API_URL } from "./client.ts";
|
||||||
|
|
||||||
const getStreamStatus = async (): Promise<boolean> => {
|
const getStreamStatus = async (): Promise<boolean> => {
|
||||||
try {
|
const response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' });
|
||||||
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/stream/current');
|
if (!response.ok) return false;
|
||||||
return result.status === 'success' && result.data?.isStreaming === true;
|
const result: APIResponse = await response.json();
|
||||||
} catch {
|
return result.status === 'success' && result.data?.isStreaming === true;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCurrentStream = async (): Promise<StreamStatus> => {
|
const getCurrentStream = async (): Promise<StreamStatus> => {
|
||||||
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/stream/current');
|
const response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' });
|
||||||
|
if (!response.ok) throw new Error(`Failed to fetch stream status: ${response.status}`);
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
if (result.status === 'error') throw new Error(`Failed to fetch stream status: ${result.message}`);
|
if (result.status === 'error') throw new Error(`Failed to fetch stream status: ${result.message}`);
|
||||||
|
|
||||||
return result.data;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStreamHistory = async (userId: number): Promise<StreamHistoryItem[]> => {
|
const getStreamHistory = async (userId: number): Promise<StreamHistoryItem[]> => {
|
||||||
const result = await apiFetch<APIResponse>(API_URL + `/api/v1/stream/history/${userId}`);
|
const response = await fetch(API_URL + `/api/v1/stream/history/${userId}`, { credentials: 'include' });
|
||||||
|
if (!response.ok) throw new Error(`Failed to fetch stream history: ${response.status}`);
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
if (result.status === 'error') throw new Error(`Failed to fetch stream history: ${result.message}`);
|
if (result.status === 'error') throw new Error(`Failed to fetch stream history: ${result.message}`);
|
||||||
|
|
||||||
return result.data;
|
return result.data;
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import type { APIResponse, User } from "../types.ts";
|
import type { APIResponse, User } from "../types.ts";
|
||||||
import { API_URL, apiFetch } from "./client.ts";
|
import { API_URL } from "./client.ts";
|
||||||
|
|
||||||
// login and getUser deliberately use fetch directly: they run when there may be
|
|
||||||
// no session at all, and getUser is what the unauthorized handler calls to
|
|
||||||
// refresh — routing them through apiFetch would loop on a 401.
|
|
||||||
const login = async (googleToken: string): Promise<string> => {
|
const login = async (googleToken: string): Promise<string> => {
|
||||||
const response = await fetch(API_URL + '/api/v1/users/login', {
|
const response = await fetch(API_URL + '/api/v1/users/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -21,8 +18,15 @@ const login = async (googleToken: string): Promise<string> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const logout = async (): Promise<void> => {
|
const logout = async (): Promise<void> => {
|
||||||
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/users/logout', { method: 'POST' });
|
const response = await fetch(API_URL + '/api/v1/users/logout', {
|
||||||
if (result?.status === 'error') throw new Error(`Logout failed: ${result.message}`);
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Logout failed: ${response.status}`);
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
if (result.status === 'error') throw new Error(`Logout failed: ${result.message}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getUser = async (): Promise<User | null> => {
|
const getUser = async (): Promise<User | null> => {
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
import type { APIResponse, Vod } from "../types.ts";
|
import type { APIResponse, Vod } from "../types.ts";
|
||||||
import { API_URL, apiFetch } from "./client.ts";
|
import { API_URL, AuthError } from "./client.ts";
|
||||||
|
|
||||||
const getVods = async (): Promise<Vod[]> => {
|
const getVods = async (): Promise<Vod[]> => {
|
||||||
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/vods');
|
const response = await fetch(API_URL + '/api/v1/vods', { credentials: 'include' });
|
||||||
if (result.status === 'error') throw new Error(`Failed to fetch vods: ${result.message}`);
|
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorResult: APIResponse = await response.json();
|
||||||
|
throw new Error(`Failed to fetch vods: ${errorResult.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
return result.data;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getVodById = async (id: string): Promise<Vod | null> => {
|
const getVodById = async (id: string): Promise<Vod | null> => {
|
||||||
const result = await apiFetch<APIResponse>(API_URL + `/api/v1/vods/${id}`);
|
const response = await fetch(API_URL + `/api/v1/vods/${id}`, { credentials: 'include' });
|
||||||
if (result.status === 'error') throw new Error(`Failed to fetch vod: ${result.message}`);
|
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401 || response.status === 403) throw new AuthError();
|
||||||
|
throw new Error(`Failed to fetch vod: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
return result.data;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -21,20 +31,31 @@ const getVodById = async (id: string): Promise<Vod | null> => {
|
|||||||
const getVodMediaUrl = (id: string): string => API_URL + `/api/v1/vods/${id}/media`;
|
const getVodMediaUrl = (id: string): string => API_URL + `/api/v1/vods/${id}/media`;
|
||||||
|
|
||||||
const patchVod = async (id: number, data: { title?: string; description?: string }): Promise<Vod> => {
|
const patchVod = async (id: number, data: { title?: string; description?: string }): Promise<Vod> => {
|
||||||
const result = await apiFetch<APIResponse>(API_URL + `/api/v1/vods/${id}`, {
|
const response = await fetch(API_URL + `/api/v1/vods/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Failed to update vod: ${response.status}`);
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
if (result.status === 'error') throw new Error(`Failed to update vod: ${result.message}`);
|
if (result.status === 'error') throw new Error(`Failed to update vod: ${result.message}`);
|
||||||
|
|
||||||
return result.data;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteVod = async (id: number): Promise<void> => {
|
const deleteVod = async (id: number): Promise<void> => {
|
||||||
const result = await apiFetch<APIResponse>(API_URL + `/api/v1/vods/${id}`, { method: 'DELETE' });
|
const response = await fetch(API_URL + `/api/v1/vods/${id}`, {
|
||||||
if (result?.status === 'error') throw new Error(`Failed to delete vod: ${result.message}`);
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Failed to delete vod: ${response.status}`);
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
if (result.status === 'error') throw new Error(`Failed to delete vod: ${result.message}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export { getVods, getVodById, getVodMediaUrl, patchVod, deleteVod };
|
export { getVods, getVodById, getVodMediaUrl, patchVod, deleteVod };
|
||||||
|
|||||||
@@ -21,12 +21,6 @@ function formatLocalDate(dateString: string, options?: Intl.DateTimeFormatOption
|
|||||||
return stringToDate(dateString).toLocaleDateString(undefined, options ?? { year: "numeric", month: "long", day: "numeric" });
|
return stringToDate(dateString).toLocaleDateString(undefined, options ?? { year: "numeric", month: "long", day: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clock time only, no date — markers within a stream are all from the same day,
|
|
||||||
// so the date would just be noise beside them.
|
|
||||||
function formatLocalTime(dateString: string): string {
|
|
||||||
return stringToDate(dateString).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function dateToTimeAgo(date: Date): string {
|
function dateToTimeAgo(date: Date): string {
|
||||||
const secondsAgo = Math.floor((Date.now() - date.getTime()) / 1000);
|
const secondsAgo = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||||
const units: [number, string][] = [
|
const units: [number, string][] = [
|
||||||
@@ -46,4 +40,4 @@ function dateToTimeAgo(date: Date): string {
|
|||||||
return `${s} second${s !== 1 ? 's' : ''} ago`;
|
return `${s} second${s !== 1 ? 's' : ''} ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { formatTime, stringToDate, dateToTimeAgo, formatLocalDate, formatLocalTime };
|
export { formatTime, stringToDate, dateToTimeAgo, formatLocalDate };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
FROM tiangolo/nginx-rtmp:latest-2026-09-07
|
FROM tiangolo/nginx-rtmp
|
||||||
|
|
||||||
RUN apt-get update -o Acquire::Check-Valid-Until=false && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends gettext-base && \
|
apt-get install -y --no-install-recommends gettext-base && \
|
||||||
apt-get clean && \
|
apt-get clean && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|||||||
@@ -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,14 @@ 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.http.HttpHeaders;
|
||||||
|
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 +26,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 +41,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 +54,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 +65,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,13 +78,6 @@ public class ClipController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Streams the clip's video file inline, supporting HTTP range requests.
|
|
||||||
*
|
|
||||||
* @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)
|
@GetMapping(value = "/{id}/media", produces = MediaType.ALL_VALUE)
|
||||||
public ResponseEntity<Resource> downloadClip(@PathVariable Long id) throws IOException {
|
public ResponseEntity<Resource> downloadClip(@PathVariable Long id) throws IOException {
|
||||||
Resource resource = clipService.downloadClip(id);
|
Resource resource = clipService.downloadClip(id);
|
||||||
@@ -124,15 +95,8 @@ public class ClipController {
|
|||||||
.body(resource);
|
.body(resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Streams the clip's thumbnail image with a public cache policy.
|
|
||||||
*
|
|
||||||
* @param id the ID of the clip whose thumbnail to download
|
|
||||||
* @return {@code 200 OK} with the thumbnail resource, or {@code 404 Not Found} if the file is missing
|
|
||||||
* @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 +104,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,12 +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)
|
@ExceptionHandler(ClipMediaUnavailable.class)
|
||||||
public ResponseEntity<APIResponse<Void>> handleClipMediaUnavailable(ClipMediaUnavailable ex) {
|
public ResponseEntity<APIResponse<Void>> handleClipMediaUnavailable(ClipMediaUnavailable ex) {
|
||||||
logger.error("ClipMediaUnavailable (clip {}): {}", ex.getClipId(), ex.getMessage());
|
logger.error("ClipMediaUnavailable (clip {}): {}", ex.getClipId(), ex.getMessage());
|
||||||
@@ -187,12 +109,6 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(response);
|
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,12 +116,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)
|
@ExceptionHandler(VodMediaUnavailable.class)
|
||||||
public ResponseEntity<APIResponse<Void>> handleVodMediaUnavailable(VodMediaUnavailable ex) {
|
public ResponseEntity<APIResponse<Void>> handleVodMediaUnavailable(VodMediaUnavailable ex) {
|
||||||
logger.error("VodMediaUnavailable (vod {}): {}", ex.getVodId(), ex.getMessage());
|
logger.error("VodMediaUnavailable (vod {}): {}", ex.getVodId(), ex.getMessage());
|
||||||
@@ -213,12 +123,6 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(response);
|
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 +130,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();
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
package com.ddf.vodsystem.controllers;
|
package com.ddf.vodsystem.controllers;
|
||||||
|
|
||||||
import com.ddf.vodsystem.controllers.dto.StreamKey;
|
|
||||||
import com.ddf.vodsystem.dto.APIResponse;
|
import com.ddf.vodsystem.dto.APIResponse;
|
||||||
import com.ddf.vodsystem.controllers.dto.Token;
|
import com.ddf.vodsystem.controllers.dto.Token;
|
||||||
import com.ddf.vodsystem.entities.Stream;
|
|
||||||
import com.ddf.vodsystem.entities.User;
|
import com.ddf.vodsystem.entities.User;
|
||||||
import com.ddf.vodsystem.exceptions.NotAuthenticated;
|
import com.ddf.vodsystem.exceptions.NotAuthenticated;
|
||||||
import com.ddf.vodsystem.services.UserService;
|
import com.ddf.vodsystem.services.UserService;
|
||||||
@@ -29,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();
|
||||||
@@ -48,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,
|
||||||
@@ -80,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) {
|
||||||
@@ -104,12 +78,4 @@ public class UserController {
|
|||||||
new APIResponse<>(SUCCESS, "Logged out successfully", null)
|
new APIResponse<>(SUCCESS, "Logged out successfully", null)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/me/stream-key")
|
|
||||||
public ResponseEntity<APIResponse<StreamKey>> regenerateStreamKey() {
|
|
||||||
String streamKey = userService.regenerateStreamKey();
|
|
||||||
return ResponseEntity.ok(
|
|
||||||
new APIResponse<>(SUCCESS, "Stream key successfully", new StreamKey(streamKey))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ 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.http.HttpHeaders;
|
||||||
|
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 +26,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 +35,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 +48,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,13 +55,6 @@ public class VodController {
|
|||||||
"Vod with ID " + id + " has been deleted"));
|
"Vod with ID " + id + " has been deleted"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Streams the VoD's video file inline, supporting HTTP range requests.
|
|
||||||
*
|
|
||||||
* @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)
|
@GetMapping(value = "/{id}/media", produces = MediaType.ALL_VALUE)
|
||||||
public ResponseEntity<Resource> downloadVod(@PathVariable Long id) throws IOException {
|
public ResponseEntity<Resource> downloadVod(@PathVariable Long id) throws IOException {
|
||||||
Resource resource = vodService.downloadVod(id);
|
Resource resource = vodService.downloadVod(id);
|
||||||
@@ -101,15 +72,8 @@ public class VodController {
|
|||||||
.body(resource);
|
.body(resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Streams the VoD's thumbnail image with a public cache policy.
|
|
||||||
*
|
|
||||||
* @param id the ID of the VoD whose thumbnail to download
|
|
||||||
* @return {@code 200 OK} with the thumbnail resource, or {@code 404 Not Found} if the file is missing
|
|
||||||
* @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 +81,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);
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
package com.ddf.vodsystem.controllers.dto;
|
|
||||||
|
|
||||||
public record StreamKey (String streamKey) {
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -12,11 +12,6 @@ public class ClipMediaUnavailable extends RuntimeException {
|
|||||||
this.clipId = clipId;
|
this.clipId = clipId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the ID of the clip whose media was unavailable.
|
|
||||||
*
|
|
||||||
* @return the affected clip's ID
|
|
||||||
*/
|
|
||||||
public Long getClipId() {
|
public Long getClipId() {
|
||||||
return clipId;
|
return clipId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,6 @@ public class VodMediaUnavailable extends RuntimeException {
|
|||||||
this.vodId = vodId;
|
this.vodId = vodId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the ID of the VoD whose media was unavailable.
|
|
||||||
*
|
|
||||||
* @return the affected VoD's ID
|
|
||||||
*/
|
|
||||||
public Long getVodId() {
|
public Long getVodId() {
|
||||||
return vodId;
|
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)
|
||||||
|
|||||||
@@ -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,15 +57,8 @@ public class MarkerService {
|
|||||||
return markerRepository.findByUser(user.get());
|
return markerRepository.findByUser(user.get());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public Marker create(String message) {
|
||||||
* Creates a marker timestamped now on the current user's active stream.
|
Optional<Stream> stream = streamService.getActiveStream();
|
||||||
*
|
|
||||||
* @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(User user, String message) {
|
|
||||||
Optional<Stream> stream = streamService.getActiveStream(user);
|
|
||||||
|
|
||||||
if (stream.isEmpty()) {
|
if (stream.isEmpty()) {
|
||||||
throw new NotStreaming("User must be streaming to mark");
|
throw new NotStreaming("User must be streaming to mark");
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ public class MediaService {
|
|||||||
onSuccess.run();
|
onSuccess.run();
|
||||||
}).exceptionally(ex -> {
|
}).exceptionally(ex -> {
|
||||||
job.setState(JobState.FAILED);
|
job.setState(JobState.FAILED);
|
||||||
job.setErrorOutput("FFMPEG failed to save stream segments");
|
job.setErrorOutput(ex.getMessage());
|
||||||
logger.error("Stream save job {} failed: {}", job.getUuid(), ex.getMessage());
|
logger.error("Stream save job {} failed: {}", job.getUuid(), ex.getMessage());
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,25 +67,16 @@ public class StreamService {
|
|||||||
streamRepository.saveAndFlush(stream);
|
streamRepository.saveAndFlush(stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public Optional<Stream> getActiveStream() {
|
||||||
* Returns the user's active (not-yet-ended) stream, if any.
|
Optional<User> user = userService.getLoggedInUser();
|
||||||
*
|
|
||||||
* @param user the User object
|
if (user.isEmpty()) {
|
||||||
* @return the active {@link Stream}, or empty if the user is not currently streaming
|
throw new NotAuthenticated("Log in to see user streams");
|
||||||
* @throws NotAuthenticated if no user session is present
|
}
|
||||||
*/
|
|
||||||
public Optional<Stream> getActiveStream(User user) {
|
return streamRepository.findByUserAndEndDateIsNull(user.get());
|
||||||
return streamRepository.findByUserAndEndDateIsNull(user);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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));
|
||||||
@@ -125,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,22 +70,10 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
public String regenerateStreamKey(User user) {
|
|
||||||
user.setStreamKey(generateStreamKey());
|
|
||||||
userRepository.saveAndFlush(user);
|
|
||||||
return user.getStreamKey();
|
|
||||||
}
|
|
||||||
|
|
||||||
private User createOrUpdateUser(User user) {
|
private User createOrUpdateUser(User user) {
|
||||||
Optional<User> existingUser = userRepository.findByGoogleId(user.getGoogleId());
|
Optional<User> existingUser = userRepository.findByGoogleId(user.getGoogleId());
|
||||||
|
|
||||||
|
|||||||
@@ -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,33 +43,15 @@ 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()) {
|
||||||
float timeInSeconds = Float.parseFloat(matcher.group(1)) / 1_000_000f;
|
float timeInMs = Float.parseFloat(matcher.group(1)) / 1000000f;
|
||||||
float fraction = Math.clamp(timeInSeconds / length, 0f, 1f);
|
progress.setProgress(timeInMs / length);
|
||||||
progress.setProgress(fraction);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ 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.concurrent.ExecutionException;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,14 +42,13 @@ public class StreamActionsService {
|
|||||||
* A resolved section of a stream, ready to be muxed by {@link #saveSection}.
|
* A resolved section of a stream, ready to be muxed by {@link #saveSection}.
|
||||||
*
|
*
|
||||||
* @param segments the segments to concatenate, in playback order; never empty
|
* @param segments the segments to concatenate, in playback order; never empty
|
||||||
* @param trimOffset seconds between the start of {@code segments.getFirst()} and
|
* @param trimOffset seconds to trim from the start of {@code segments.getFirst()}
|
||||||
* the requested start, or {@code 0} when the first segment
|
* so playback begins at the requested moment, or {@code 0}
|
||||||
* already starts there. Segments are copied whole, so this is
|
* when the first segment already starts there
|
||||||
* lead-in the section carries before the requested moment, not
|
* @param duration seconds of footage to keep, measured from the trim point.
|
||||||
* footage that gets trimmed away
|
* When the requested start fell in a recording gap and the
|
||||||
* @param duration seconds of footage to keep, measured from the start of
|
* leading segment was dropped, this is measured from the
|
||||||
* {@code segments.getFirst()} so the section ends at the
|
* later real start so the section still ends where requested
|
||||||
* requested end time with the lead-in included
|
|
||||||
*/
|
*/
|
||||||
public record SegmentSelection(List<Path> segments, float trimOffset, float duration) {}
|
public record SegmentSelection(List<Path> segments, float trimOffset, float duration) {}
|
||||||
|
|
||||||
@@ -80,6 +78,8 @@ public class StreamActionsService {
|
|||||||
long firstMs = parseTimestampMs(segments.getFirst());
|
long firstMs = parseTimestampMs(segments.getFirst());
|
||||||
float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstMs) / 1000f);
|
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) {
|
if (trimOffset > 0 && probeDurationSeconds(segments.getFirst()) < trimOffset) {
|
||||||
segments.removeFirst();
|
segments.removeFirst();
|
||||||
if (segments.isEmpty()) {
|
if (segments.isEmpty()) {
|
||||||
@@ -89,19 +89,18 @@ public class StreamActionsService {
|
|||||||
trimOffset = 0f;
|
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;
|
||||||
|
|
||||||
float duration = (endTime.toEpochMilli() - firstMs) / 1000f;
|
|
||||||
return new SegmentSelection(segments, trimOffset, duration);
|
return new SegmentSelection(segments, trimOffset, duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Muxes a {@link SegmentSelection} to {@code outputFile}, keeping
|
* Muxes a {@link SegmentSelection} to {@code outputFile}, trimming the leading
|
||||||
* {@code selection.duration()} seconds of footage.
|
* segment to {@code selection.trimOffset()} and keeping {@code selection.duration()}
|
||||||
* <p>
|
* seconds of footage.
|
||||||
* Segments are concatenated byte-wise through ffmpeg's {@code concat:} protocol,
|
|
||||||
* which preserves the continuous timestamps nginx writes across fragments. The
|
|
||||||
* leading segment is copied whole rather than trimmed, so playback begins up to
|
|
||||||
* {@code selection.trimOffset()} seconds before the requested moment.
|
|
||||||
* <p>
|
* <p>
|
||||||
* Runs asynchronously; failures (process-launch errors, ffmpeg failures,
|
* Runs asynchronously; failures (process-launch errors, ffmpeg failures,
|
||||||
* interruption) complete the returned future exceptionally rather than being
|
* interruption) complete the returned future exceptionally rather than being
|
||||||
@@ -132,19 +131,37 @@ public class StreamActionsService {
|
|||||||
"trimOffset must be >= 0 and duration must be > 0 (got " + trimOffset + ", " + duration + ")"));
|
"trimOffset must be >= 0 and duration must be > 0 (got " + trimOffset + ", " + duration + ")"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Path headFragment = null;
|
||||||
|
Path concatList = null;
|
||||||
try {
|
try {
|
||||||
String concatInput = "concat:" + segments.stream()
|
// Only the first segment is trimmed, so re-encode just that fragment and
|
||||||
.map(p -> p.toAbsolutePath().toString())
|
// stream-copy the rest. A zero offset means the first segment already
|
||||||
.collect(Collectors.joining("|"));
|
// starts at the requested point, so re-encoding it would be wasted work.
|
||||||
|
List<Path> fragments;
|
||||||
|
if (trimOffset > 0) {
|
||||||
|
headFragment = Files.createTempFile("reencode-section-", ".ts");
|
||||||
|
encodeHead(segments.getFirst(), trimOffset, headFragment);
|
||||||
|
|
||||||
|
fragments = new ArrayList<>();
|
||||||
|
fragments.add(headFragment);
|
||||||
|
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",
|
||||||
"-progress", "pipe:1",
|
"-f", "concat",
|
||||||
"-i", concatInput,
|
"-safe", "0",
|
||||||
|
"-i", concatList.toAbsolutePath().toString(),
|
||||||
"-t", String.valueOf(duration),
|
"-t", String.valueOf(duration),
|
||||||
"-c", "copy",
|
"-c", "copy",
|
||||||
"-avoid_negative_ts", "make_zero",
|
|
||||||
outputFile.toAbsolutePath().toString()
|
outputFile.toAbsolutePath().toString()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -163,6 +180,9 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,4 +257,52 @@ public class StreamActionsService {
|
|||||||
throw new IOException("Failed to probe segment duration: " + segment, 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);
|
||||||
|
|||||||
@@ -3,6 +3,4 @@ spring.docker.compose.file=docker-compose.dev.yml
|
|||||||
media.temp.inputs=./videos/inputs
|
media.temp.inputs=./videos/inputs
|
||||||
media.temp.outputs=./videos/outputs
|
media.temp.outputs=./videos/outputs
|
||||||
media.users=${MEDIA_DATA}
|
media.users=${MEDIA_DATA}
|
||||||
media.streams=${STREAM_DATA}
|
media.streams=${STREAM_DATA}
|
||||||
|
|
||||||
logging.level.com.ddf.vodsystem=DEBUG
|
|
||||||
@@ -57,6 +57,23 @@ class StreamActionsServiceTest {
|
|||||||
when(metadataService.getVideoMetadata(any())).thenReturn(CompletableFuture.completedFuture(meta));
|
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
|
// selectSegments: missing / invalid directories
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
@@ -230,10 +247,8 @@ class StreamActionsServiceTest {
|
|||||||
assertThat(selection.segments()).extracting(p -> p.getFileName().toString())
|
assertThat(selection.segments()).extracting(p -> p.getFileName().toString())
|
||||||
.containsExactly("30.ts", "33.ts");
|
.containsExactly("30.ts", "33.ts");
|
||||||
assertThat(selection.trimOffset()).isEqualTo(1.5f);
|
assertThat(selection.trimOffset()).isEqualTo(1.5f);
|
||||||
// 30.ts is copied whole, so the section starts at 30s rather than the
|
// effective start equals the requested 31.5s, so duration runs to endTime: 40 - 31.5
|
||||||
// requested 31.5s; duration runs from there to endTime: 40 - 30. The
|
assertThat(selection.duration()).isEqualTo(8.5f);
|
||||||
// 1.5s trimOffset is carried as lead-in, not cut away.
|
|
||||||
assertThat(selection.duration()).isEqualTo(10f);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -340,7 +355,7 @@ class StreamActionsServiceTest {
|
|||||||
ProgressTracker progress = new ProgressTracker();
|
ProgressTracker progress = new ProgressTracker();
|
||||||
CommandOutput commandOutput = new CommandOutput();
|
CommandOutput commandOutput = new CommandOutput();
|
||||||
|
|
||||||
when(commandRunner.run(anyList(), any())).thenReturn(commandOutput);
|
stubRunnerProducingHead(commandOutput);
|
||||||
|
|
||||||
var future = service.saveSection(
|
var future = service.saveSection(
|
||||||
new StreamActionsService.SegmentSelection(List.of(segmentA, segmentB), 5.0f, 12.0f), output, progress);
|
new StreamActionsService.SegmentSelection(List.of(segmentA, segmentB), 5.0f, 12.0f), output, progress);
|
||||||
@@ -348,29 +363,24 @@ class StreamActionsServiceTest {
|
|||||||
assertThat(future.get()).isSameAs(commandOutput);
|
assertThat(future.get()).isSameAs(commandOutput);
|
||||||
assertThat(progress.isComplete()).isTrue();
|
assertThat(progress.isComplete()).isTrue();
|
||||||
|
|
||||||
// The section is one stream-copy pass over the byte-concatenated segments:
|
// With a non-zero trim offset saveSection issues two ffmpeg passes: the
|
||||||
// the concat: protocol joins them without rebasing the timestamps nginx
|
// head re-encode (trims the first segment via -ss) followed by the
|
||||||
// wrote across fragments, which is what keeps the joins seamless.
|
// concat-demuxer stream copy (-f concat / -t / -c copy).
|
||||||
@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, Mockito.times(2)).run(captor.capture(), any());
|
||||||
List<String> command = captor.getValue();
|
List<String> headCommand = captor.getAllValues().get(0);
|
||||||
|
List<String> concatCommand = captor.getAllValues().get(1);
|
||||||
|
|
||||||
assertThat(command).containsSequence(
|
assertThat(headCommand).containsSequence("-ss", "5.0");
|
||||||
"-i", "concat:" + segmentA.toAbsolutePath() + "|" + segmentB.toAbsolutePath());
|
assertThat(concatCommand).containsSequence("-f", "concat");
|
||||||
assertThat(command).containsSequence("-t", "12.0");
|
assertThat(concatCommand).containsSequence("-t", "12.0");
|
||||||
assertThat(command).containsSequence("-c", "copy");
|
assertThat(concatCommand).containsSequence("-c", "copy");
|
||||||
assertThat(command).containsSequence("-avoid_negative_ts", "make_zero");
|
assertThat(concatCommand).endsWith(output.toAbsolutePath().toString());
|
||||||
assertThat(command).endsWith(output.toAbsolutePath().toString());
|
|
||||||
|
|
||||||
// The leading segment is copied whole, so nothing seeks and the concat
|
|
||||||
// demuxer (-f concat) is deliberately not used.
|
|
||||||
assertThat(command).doesNotContain("-ss");
|
|
||||||
assertThat(command).doesNotContainSequence("-f", "concat");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void saveSection_zeroTrimOffset_buildsTheSameSingleCopyPass() throws Exception {
|
void saveSection_zeroTrimOffset_skipsHeadReencodeAndRunsSingleConcatPass() throws Exception {
|
||||||
Path output = Path.of("/tmp/out/clip.mp4");
|
Path output = Path.of("/tmp/out/clip.mp4");
|
||||||
ProgressTracker progress = new ProgressTracker();
|
ProgressTracker progress = new ProgressTracker();
|
||||||
CommandOutput commandOutput = new CommandOutput();
|
CommandOutput commandOutput = new CommandOutput();
|
||||||
@@ -384,14 +394,14 @@ class StreamActionsServiceTest {
|
|||||||
assertThat(future.get()).isSameAs(commandOutput);
|
assertThat(future.get()).isSameAs(commandOutput);
|
||||||
assertThat(progress.isComplete()).isTrue();
|
assertThat(progress.isComplete()).isTrue();
|
||||||
|
|
||||||
// trimOffset no longer changes the command shape: with or without lead-in
|
// trimOffset 0 means the first segment already starts at the requested point:
|
||||||
// the section is the same single copy pass over the joined segments.
|
// 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, Mockito.times(1)).run(captor.capture(), any());
|
||||||
List<String> command = captor.getValue();
|
List<String> command = captor.getValue();
|
||||||
|
|
||||||
assertThat(command).containsSequence("-i", "concat:/tmp/a.ts|/tmp/b.ts");
|
assertThat(command).containsSequence("-f", "concat");
|
||||||
assertThat(command).containsSequence("-t", "10.0");
|
assertThat(command).containsSequence("-t", "10.0");
|
||||||
assertThat(command).doesNotContain("-ss");
|
assertThat(command).doesNotContain("-ss");
|
||||||
}
|
}
|
||||||
@@ -401,7 +411,8 @@ 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();
|
||||||
|
|
||||||
// The copy pass is the only ffmpeg invocation, so its failure must surface.
|
// 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(
|
||||||
@@ -431,8 +442,8 @@ class StreamActionsServiceTest {
|
|||||||
void saveSection_nullOutputFile_completesExceptionally() {
|
void saveSection_nullOutputFile_completesExceptionally() {
|
||||||
ProgressTracker progress = new ProgressTracker();
|
ProgressTracker progress = new ProgressTracker();
|
||||||
|
|
||||||
// Building the command dereferences the null output path, so no ffmpeg
|
// trimOffset 0 skips the head pass; building the concat command dereferences
|
||||||
// pass ever runs.
|
// the null output path, so no ffmpeg pass ever runs.
|
||||||
var future = service.saveSection(
|
var future = service.saveSection(
|
||||||
new StreamActionsService.SegmentSelection(List.of(Path.of("/tmp/a.ts")), 0f, 10f), null, progress);
|
new StreamActionsService.SegmentSelection(List.of(Path.of("/tmp/a.ts")), 0f, 10f), null, progress);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user