8 Commits

Author SHA1 Message Date
ec9a982fe9 docs(docstrings): add docstrings to public method interfaces (#25)
All checks were successful
CI / Maven unit tests (push) Successful in 18s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
- Resolves #18

Reviewed-on: #25
2026-08-24 16:02:04 +01:00
38942776df refactor(frontend): create centralised auth manager (#24)
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
- Resolves #17

Reviewed-on: #24
2026-08-22 21:06:38 +01:00
bf5fe6a35d build(ci): update site url
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-08-21 14:54:21 +01:00
9033261165 feat(clipping): add title requirement check
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Successful in 48s
CI / Deploy to server (push) Successful in 8s
2026-08-19 18:34:11 +01:00
9e82dc8574 fix(upload): compression job does not begin while unauthenticated (#22)
All checks were successful
CI / Maven unit tests (push) Successful in 13s
CI / Build and push images (push) Successful in 2m30s
CI / Deploy to server (push) Successful in 40s
Resolves #20

Reviewed-on: https://gitea.vodsystem.ddns.net/dylan/vod-system/pulls/22
2026-08-19 18:22:49 +01:00
1c098028ab perf(frontend): implement thumbnail caching (#19)
All checks were successful
CI / Maven unit tests (push) Successful in 13s
CI / Build and push images (push) Successful in 2m24s
CI / Deploy to server (push) Successful in 13s
- Simply uses the Spring Side cache headers, which permits the browser to cache the thumbnail
- Max cache time of 2 days before re-fetch required
- Closes #13

Reviewed-on: https://gitea.vodsystem.ddns.net/dylan/vod-system/pulls/19
2026-08-18 21:47:47 +01:00
9c6002fe77 fix(no access page): missing mono colour scheme
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-08-17 23:13:52 +01:00
2f33101ec3 build(frontend): update docker image to use production env
All checks were successful
CI / Maven unit tests (push) Successful in 13s
CI / Build and push images (push) Successful in 27s
CI / Deploy to server (push) Successful in 8s
2026-08-17 23:06:25 +01:00
56 changed files with 931 additions and 112 deletions

View File

@@ -4,7 +4,7 @@ on:
push: push:
env: env:
REGISTRY: gitea.vodsystem.ddns.net REGISTRY: gitea.defaoite.dev
IMAGE_PREFIX: dylan/vod-system IMAGE_PREFIX: dylan/vod-system
jobs: jobs:

View File

@@ -20,12 +20,11 @@ services:
context: ./nginx context: ./nginx
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: vod_rtmp container_name: vod_rtmp
extra_hosts: network_mode: host
- "host.docker.internal:host-gateway"
env_file: env_file:
- .env - .env
environment: environment:
- WEB_SERVER_URL=http://host.docker.internal:8080 - WEB_SERVER_URL=http://localhost:8080
ports: ports:
- "1935:1935" - "1935:1935"
volumes: volumes:

View File

@@ -15,7 +15,7 @@ services:
restart: unless-stopped restart: unless-stopped
backend: backend:
image: gitea.vodsystem.ddns.net/dylan/vod-system-backend:latest image: gitea.defaoite.dev/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.vodsystem.ddns.net/dylan/vod-system-frontend:latest image: gitea.defaoite.dev/dylan/vod-system-frontend:latest
container_name: vod_frontend container_name: vod_frontend
environment: environment:
VITE_API_URL: ${BACKEND_URL} VITE_API_URL: ${BACKEND_URL}
@@ -38,11 +38,11 @@ services:
depends_on: depends_on:
- backend - backend
ports: ports:
- "5173:5173" - "5173:80"
restart: unless-stopped restart: unless-stopped
rtmp: rtmp:
image: gitea.vodsystem.ddns.net/dylan/vod-system-rtmp:latest image: gitea.defaoite.dev/dylan/vod-system-rtmp:latest
container_name: vod_rtmp container_name: vod_rtmp
env_file: env_file:
- .env - .env

View File

@@ -37,7 +37,7 @@ services:
depends_on: depends_on:
- backend - backend
ports: ports:
- "5173:5173" - "5173:80"
restart: unless-stopped restart: unless-stopped
rtmp: rtmp:

View File

@@ -1,9 +1,19 @@
FROM node:20-alpine # --- Build stage ---
FROM node:20-alpine AS build
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci RUN --mount=type=cache,target=/root/.npm npm ci
COPY . . COPY . .
EXPOSE 5173 RUN npm run build
CMD ["npm", "run", "dev"]
# --- Production stage ---
FROM nginx:1.27-alpine AS prod
COPY --from=build /app/dist /usr/share/nginx/html
COPY frontend_nginx.conf /etc/nginx/conf.d/default.conf
# Inject envs at runtime
COPY env.sh /docker-entrypoint.d/40-env.sh
RUN chmod +x /docker-entrypoint.d/40-env.sh
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

10
frontend/env.sh Normal file
View File

@@ -0,0 +1,10 @@
#!/bin/sh
set -eu
cat <<EOF > /usr/share/nginx/html/env-config.js
window._env_ = {
VITE_API_URL: "${VITE_API_URL:-}",
VITE_FRONTEND_URL: "${VITE_FRONTEND_URL:-}",
VITE_GOOGLE_CLIENT_ID: "${VITE_GOOGLE_CLIENT_ID:-}"
};
EOF

View File

@@ -0,0 +1,15 @@
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;
}
}

View File

@@ -9,6 +9,7 @@
<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 src="/env-config.js"></script>
<title>The VoD System</title> <title>The VoD System</title>
</head> </head>
<body> <body>

View File

@@ -15,6 +15,9 @@ import EditVod from "./pages/EditVod.tsx";
import Profile from "./pages/Profile.tsx"; import Profile from "./pages/Profile.tsx";
import StreamControl from "./pages/StreamControl.tsx"; import StreamControl from "./pages/StreamControl.tsx";
import { GoogleOAuthProvider } from '@react-oauth/google'; import { GoogleOAuthProvider } from '@react-oauth/google';
import {config} from "./config.ts";
import NotFound from "./pages/NotFound.tsx";
import AuthProvider from "./auth/AuthProvider.tsx";
function App() { function App() {
@@ -23,24 +26,27 @@ function App() {
}, []); }, []);
return ( return (
<GoogleOAuthProvider clientId={import.meta.env.VITE_GOOGLE_CLIENT_ID}> <GoogleOAuthProvider clientId={config.googleClientId}>
<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="/my-clips" element={<MyClips />} />
<Route path="/video/:id" element={<MediaPlayer noun="Clip" getMediaUrl={getClipMediaUrl} fetchDetails={getClipById} />} /> <Route path="/my-vods" element={<MyVods />} />
<Route path="/vod/:id" element={<MediaPlayer noun="VoD" getMediaUrl={getVodMediaUrl} fetchDetails={getVodById} />} /> <Route path="/video/:id" element={<MediaPlayer noun="Clip" getMediaUrl={getClipMediaUrl} fetchDetails={getClipById} />} />
<Route path="/clips/:id/edit" element={<EditClip />} /> <Route path="/vod/:id" element={<MediaPlayer noun="VoD" getMediaUrl={getVodMediaUrl} fetchDetails={getVodById} />} />
<Route path="/vods/:id/edit" element={<EditVod />} /> <Route path="/clips/:id/edit" element={<EditClip />} />
<Route path="/profile" element={<Profile />} /> <Route path="/vods/:id/edit" element={<EditVod />} />
<Route path="/stream" element={<StreamControl />} /> <Route path="/profile" element={<Profile />} />
</Route> <Route path="/stream" element={<StreamControl />} />
</Routes> <Route path="*" element={<NotFound />} />
</Router> </Route>
</Routes>
</Router>
</AuthProvider>
</ GoogleOAuthProvider> </ GoogleOAuthProvider>
); );
} }

View File

@@ -0,0 +1,20 @@
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 };

View File

@@ -0,0 +1,46 @@
import { useCallback, useEffect, useState } from "react";
import type { ReactNode } from "react";
import type { User } from "../utils/types.ts";
import { login as apiLogin, logout as apiLogout, getUser } from "../utils/api/users.ts";
import { AuthContext } from "./AuthContext.ts";
/**
* Owns the single source of truth for the authenticated user. Everything that
* needs to know who is logged in — the layout, the top bar, protected pages —
* reads it through the `useAuth` hook rather than fetching the user itself.
*/
const AuthProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
setUser(await getUser());
} catch (error) {
console.error("Failed to fetch user:", error);
setUser(null);
}
}, []);
useEffect(() => {
refresh().finally(() => setLoading(false));
}, [refresh]);
const login = useCallback(async (googleCredential: string) => {
await apiLogin(googleCredential);
await refresh();
}, [refresh]);
const logout = useCallback(async () => {
await apiLogout();
setUser(null);
}, []);
return (
<AuthContext.Provider value={{ user, loading, login, logout, refresh }}>
{children}
</AuthContext.Provider>
);
};
export default AuthProvider;

View File

@@ -0,0 +1,14 @@
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 };

View File

@@ -1,16 +1,17 @@
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 type {User} from "../utils/types.ts"; import { useAuth } from "../auth/useAuth.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 = ({user, className}: props) => { const Sidebar = ({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>

View File

@@ -1,21 +1,18 @@
import { Menu, X } from 'lucide-react'; import { Menu, X } from 'lucide-react';
import { login, logout } from "../utils/api/users.ts";
import { Dropdown, DropdownItem } from "./Dropdown.tsx"; import { Dropdown, DropdownItem } from "./Dropdown.tsx";
import { GoogleLogin } from '@react-oauth/google'; import { GoogleLogin } from '@react-oauth/google';
import type { User } from "../utils/types.ts";
import type { CredentialResponse } from '@react-oauth/google'; import type { CredentialResponse } from '@react-oauth/google';
import MenuButton from "./buttons/MenuButton.tsx"; import MenuButton from "./buttons/MenuButton.tsx";
import clsx from "clsx"; import clsx from "clsx";
import {useNavigate} from "react-router-dom"; import {useNavigate} from "react-router-dom";
import { useAuth } from "../auth/useAuth.ts";
type props = { type props = {
sidebarToggled: boolean; sidebarToggled: boolean;
setSidebarToggled: (toggled: boolean) => void; setSidebarToggled: (toggled: boolean) => void;
user: User | null;
fetchUser: () => void;
isStreaming: boolean; isStreaming: boolean;
className?: string; className?: string;
} }
@@ -23,12 +20,11 @@ type props = {
const Topbar = ({ const Topbar = ({
sidebarToggled, sidebarToggled,
setSidebarToggled, setSidebarToggled,
user,
fetchUser,
isStreaming, isStreaming,
className}: props) => { className}: props) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { user, login, logout } = useAuth();
const handleLogin = (response: CredentialResponse) => { const handleLogin = (response: CredentialResponse) => {
if (!response.credential) { if (!response.credential) {
@@ -37,10 +33,6 @@ 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);
}); });
@@ -48,10 +40,7 @@ const Topbar = ({
const handleLogout = () => { const handleLogout = () => {
logout() logout()
.then(() => { .then(() => navigate("/"))
fetchUser();
navigate("/");
})
.catch((error) => { .catch((error) => {
console.error("Logout failed:", error); console.error("Logout failed:", error);
}); });

View File

@@ -9,9 +9,12 @@ type Props = {
const MetadataBox = ({ setMetadata, className }: Props) => ( const MetadataBox = ({ setMetadata, className }: Props) => (
<div className={clsx("flex flex-col content-between p-6 gap-2", className)}> <div className={clsx("flex flex-col content-between p-6 gap-2", className)}>
<p className="w-full font-body text-text-strong font-bold text-sm">Title</p> <p className="w-full font-body text-text-strong font-bold text-sm">
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"

View File

@@ -1,9 +1,9 @@
import clsx from "clsx"; import clsx from "clsx";
import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts"; import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { isThumbnailAvailable } from "../../utils/api/client.ts";
import { MoreVertical, Pencil, Trash2 } from "lucide-react"; import { MoreVertical, Pencil, Trash2 } from "lucide-react";
import {config} from "../../config.ts";
type VideoCardProps = { type VideoCardProps = {
id: number, id: number,
@@ -18,12 +18,12 @@ type VideoCardProps = {
itemLabel?: string, itemLabel?: string,
} }
const fallbackThumbnail = "../../../public/default_thumbnail.png"; const fallbackThumbnail = "public/default_thumbnail.png";
const API_URL = import.meta.env.VITE_API_URL; 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 [thumbnailAvailable, setThumbnailAvailable] = useState(true); const [imgFailed, setImgFailed] = useState(false);
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,12 +36,6 @@ 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) => {
@@ -65,7 +59,8 @@ 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={thumbnailAvailable ? thumbnailUrl : fallbackThumbnail} src={imgFailed ? fallbackThumbnail : thumbnailUrl}
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"
/> />

5
frontend/src/config.ts Normal file
View File

@@ -0,0 +1,5 @@
export const config = {
apiUrl: window._env_?.VITE_API_URL ?? import.meta.env.VITE_API_URL ?? '',
frontendUrl: window._env_?.VITE_FRONTEND_URL ?? import.meta.env.VITE_FRONTEND_URL ?? '',
googleClientId: window._env_?.VITE_GOOGLE_CLIENT_ID ?? import.meta.env.VITE_GOOGLE_CLIENT_ID ?? '',
};

View File

@@ -18,6 +18,7 @@
--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;

View File

@@ -3,27 +3,13 @@ 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 type {User} from "../utils/types"; import { useAuth } from "../auth/useAuth";
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;
@@ -36,14 +22,11 @@ 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 />

View File

@@ -10,8 +10,9 @@ 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 = import.meta.env.VITE_API_URL; const API_URL = config.apiUrl;
const ClipEdit = () => { const ClipEdit = () => {
const location = useLocation(); const location = useLocation();
@@ -63,12 +64,18 @@ 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); jobId = await compress(localFile, { ...outputMetadata, title });
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'}`);

View File

@@ -0,0 +1,24 @@
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;

View File

@@ -1,19 +1,14 @@
import { useState, useEffect } from "react"; import { useState } from "react";
import { Eye, EyeOff, Copy, Check } from "lucide-react"; import { Eye, EyeOff, Copy, Check } from "lucide-react";
import Box from "../components/Box.tsx"; import Box from "../components/Box.tsx";
import { getUser } from "../utils/api/users.ts"; import { useAuth } from "../auth/useAuth.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, setUser] = useState<User | null>(null); const { user } = useAuth();
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);

View File

@@ -3,12 +3,12 @@ import { Radio, Check, Bookmark, Scissors, Film, Activity, Loader2, X } from "lu
import clsx from "clsx"; import clsx from "clsx";
import Box from "../components/Box.tsx"; import Box from "../components/Box.tsx";
import PrimaryButton from "../components/buttons/PrimaryButton.tsx"; import PrimaryButton from "../components/buttons/PrimaryButton.tsx";
import { getUser } from "../utils/api/users.ts"; import { useAuth } from "../auth/useAuth.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 { User, StreamStatus, StreamHistoryItem, Marker, JobResponse } from "../utils/types.ts"; import type { StreamStatus, StreamHistoryItem, Marker, JobResponse } from "../utils/types.ts";
import { formatTime, formatLocalDate, stringToDate } from "../utils/utils.ts"; import { formatTime, formatLocalDate, stringToDate } from "../utils/utils.ts";
const inputClass = "border border-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors"; const inputClass = "border border-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors";
@@ -60,7 +60,7 @@ const JobStatus = ({ job }: { job: TrackedJob }) => {
}; };
const StreamControl = () => { const StreamControl = () => {
const [user, setUser] = useState<User | null>(null); const { user } = useAuth();
const [streamStatus, setStreamStatus] = useState<StreamStatus | null>(null); const [streamStatus, setStreamStatus] = useState<StreamStatus | null>(null);
const [streamDetails, setStreamDetails] = useState<StreamHistoryItem | null>(null); const [streamDetails, setStreamDetails] = useState<StreamHistoryItem | null>(null);
const [now, setNow] = useState(Date.now()); const [now, setNow] = useState(Date.now());
@@ -85,10 +85,6 @@ 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(() => {});

View File

@@ -1,4 +1,6 @@
const API_URL = import.meta.env.VITE_API_URL; import {config} from "../../config.ts";
const API_URL = config.apiUrl;
class AuthError extends Error { class AuthError extends Error {
constructor() { super("Not authenticated"); this.name = "AuthError"; } constructor() { super("Not authenticated"); this.name = "AuthError"; }

View File

@@ -1 +1,9 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface Window {
_env_?: {
VITE_API_URL?: string;
VITE_FRONTEND_URL?: string;
VITE_GOOGLE_CLIENT_ID?: string;
};
}

View File

@@ -10,6 +10,11 @@ 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);
} }

View File

@@ -14,6 +14,11 @@ 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();
@@ -25,6 +30,11 @@ 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) ->

View File

@@ -12,6 +12,11 @@ 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() {

View File

@@ -22,6 +22,14 @@ 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
@@ -30,8 +38,9 @@ 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()

View File

@@ -6,14 +6,12 @@ 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.HttpHeaders; import org.springframework.http.*;
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")
@@ -26,6 +24,11 @@ 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();
@@ -41,6 +44,12 @@ 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);
@@ -54,6 +63,13 @@ 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) {
@@ -65,6 +81,12 @@ 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);
@@ -78,6 +100,13 @@ 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);
@@ -95,8 +124,15 @@ 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) { public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) throws IOException {
Resource resource = clipService.downloadThumbnail(id); Resource resource = clipService.downloadThumbnail(id);
if (resource == null || !resource.exists()) { if (resource == null || !resource.exists()) {
@@ -104,6 +140,8 @@ 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);

View File

@@ -18,6 +18,12 @@ 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());
@@ -25,6 +31,12 @@ 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());
@@ -32,6 +44,12 @@ 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());
@@ -39,6 +57,12 @@ 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());
@@ -46,6 +70,12 @@ 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());
@@ -53,6 +83,12 @@ 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());
@@ -60,6 +96,12 @@ 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());
@@ -67,6 +109,12 @@ 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());
@@ -74,6 +122,12 @@ 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());
@@ -81,6 +135,12 @@ 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());
@@ -88,6 +148,12 @@ 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);
@@ -95,6 +161,12 @@ 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());
@@ -102,6 +174,12 @@ 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());
@@ -109,6 +187,12 @@ 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());
@@ -116,6 +200,12 @@ 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());
@@ -123,6 +213,12 @@ 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());
@@ -130,6 +226,12 @@ 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());

View File

@@ -28,6 +28,13 @@ 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);
@@ -47,6 +54,15 @@ 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);

View File

@@ -22,6 +22,11 @@ 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()
@@ -33,6 +38,12 @@ 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);
@@ -42,6 +53,12 @@ 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());
@@ -51,6 +68,12 @@ 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);

View File

@@ -26,6 +26,14 @@ 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,
@@ -40,6 +48,13 @@ 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 {
@@ -55,6 +70,13 @@ 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 {
@@ -70,6 +92,13 @@ 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 {

View File

@@ -24,6 +24,14 @@ 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) {
@@ -32,6 +40,14 @@ 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) {
@@ -40,6 +56,14 @@ 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) {
@@ -47,12 +71,24 @@ 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();

View File

@@ -27,6 +27,12 @@ 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();
@@ -40,6 +46,17 @@ 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,
@@ -61,6 +78,13 @@ 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) {

View File

@@ -6,14 +6,12 @@ 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.HttpHeaders; import org.springframework.http.*;
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")
@@ -26,6 +24,11 @@ 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()
@@ -35,12 +38,25 @@ 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) {
@@ -48,6 +64,12 @@ 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);
@@ -55,6 +77,13 @@ 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);
@@ -72,8 +101,15 @@ 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) { public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) throws IOException {
Resource resource = vodService.downloadThumbnail(id); Resource resource = vodService.downloadThumbnail(id);
if (resource == null || !resource.exists()) { if (resource == null || !resource.exists()) {
@@ -81,6 +117,8 @@ 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);

View File

@@ -10,6 +10,11 @@ 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);
} }

View File

@@ -4,14 +4,30 @@ 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");
@@ -19,10 +35,16 @@ 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;

View File

@@ -12,6 +12,11 @@ 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;
} }

View File

@@ -12,6 +12,11 @@ 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;
} }

View File

@@ -10,6 +10,12 @@ 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);
} }

View File

@@ -11,9 +11,21 @@ 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);
} }

View File

@@ -11,7 +11,27 @@ 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);
} }

View File

@@ -9,9 +9,21 @@ 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);
} }

View File

@@ -11,6 +11,12 @@ 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);
} }

View File

@@ -26,6 +26,12 @@ 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)
@@ -34,6 +40,12 @@ 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)

View File

@@ -40,61 +40,132 @@ 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);
@@ -146,6 +217,12 @@ 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));
@@ -155,6 +232,13 @@ 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);

View File

@@ -32,6 +32,14 @@ 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);
@@ -47,6 +55,12 @@ 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();
@@ -57,6 +71,13 @@ public class MarkerService {
return markerRepository.findByUser(user.get()); return markerRepository.findByUser(user.get());
} }
/**
* Creates a marker timestamped now on the current user's active stream.
*
* @param message the marker's descriptive message
* @return the persisted {@link Marker}
* @throws NotStreaming if the user has no active stream to mark
*/
public Marker create(String message) { public Marker create(String message) {
Optional<Stream> stream = streamService.getActiveStream(); Optional<Stream> stream = streamService.getActiveStream();
@@ -72,11 +93,23 @@ 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() {

View File

@@ -30,6 +30,14 @@ 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);
@@ -45,6 +53,13 @@ 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);
@@ -56,6 +71,13 @@ 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);
@@ -67,6 +89,12 @@ public class StreamService {
streamRepository.saveAndFlush(stream); streamRepository.saveAndFlush(stream);
} }
/**
* Returns the current user's active (not-yet-ended) stream, if any.
*
* @return the active {@link Stream}, or empty if the user is not currently streaming
* @throws NotAuthenticated if no user session is present
*/
public Optional<Stream> getActiveStream() { public Optional<Stream> getActiveStream() {
Optional<User> user = userService.getLoggedInUser(); Optional<User> user = userService.getLoggedInUser();
@@ -77,6 +105,14 @@ public class StreamService {
return streamRepository.findByUserAndEndDateIsNull(user.get()); return streamRepository.findByUserAndEndDateIsNull(user.get());
} }
/**
* Returns the full stream history for the given user.
*
* @param userId the ID of the user whose history to retrieve
* @return the user's streams, past and present
* @throws IllegalArgumentException if no user with {@code userId} exists
* @throws NotAuthenticated if no user is authenticated, or the caller is not that user
*/
public List<Stream> getStreamHistory(Long userId) { public List<Stream> getStreamHistory(Long userId) {
User streamUser = userService.getUserById(userId) User streamUser = userService.getUserById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found: " + userId)); .orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));
@@ -94,6 +130,12 @@ 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() {

View File

@@ -44,10 +44,21 @@ 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) {
@@ -56,6 +67,13 @@ 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();
@@ -70,6 +88,12 @@ 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);
} }

View File

@@ -16,6 +16,15 @@ 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);
@@ -43,10 +52,27 @@ 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()) {

View File

@@ -27,6 +27,17 @@ 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,

View File

@@ -26,6 +26,14 @@ 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);

View File

@@ -21,6 +21,17 @@ 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,

View File

@@ -20,6 +20,16 @@ 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);