Compare commits
8 Commits
b577fd9387
...
fix/start-
| Author | SHA1 | Date | |
|---|---|---|---|
| ec9a982fe9 | |||
| 38942776df | |||
| bf5fe6a35d | |||
| 9033261165 | |||
| 9e82dc8574 | |||
| 1c098028ab | |||
| 9c6002fe77 | |||
| 2f33101ec3 |
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -42,7 +42,7 @@ services:
|
|||||||
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
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ RUN npm run build
|
|||||||
# --- Production stage ---
|
# --- Production stage ---
|
||||||
FROM nginx:1.27-alpine AS prod
|
FROM nginx:1.27-alpine AS prod
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
COPY frontend_nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
# Inject envs at runtime
|
# Inject envs at runtime
|
||||||
COPY env.sh /docker-entrypoint.d/40-env.sh
|
COPY env.sh /docker-entrypoint.d/40-env.sh
|
||||||
|
|||||||
15
frontend/frontend_nginx.conf
Normal file
15
frontend/frontend_nginx.conf
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ import Profile from "./pages/Profile.tsx";
|
|||||||
import StreamControl from "./pages/StreamControl.tsx";
|
import StreamControl from "./pages/StreamControl.tsx";
|
||||||
import { GoogleOAuthProvider } from '@react-oauth/google';
|
import { GoogleOAuthProvider } from '@react-oauth/google';
|
||||||
import {config} from "./config.ts";
|
import {config} from "./config.ts";
|
||||||
|
import NotFound from "./pages/NotFound.tsx";
|
||||||
|
import AuthProvider from "./auth/AuthProvider.tsx";
|
||||||
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -25,6 +27,7 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GoogleOAuthProvider clientId={config.googleClientId}>
|
<GoogleOAuthProvider clientId={config.googleClientId}>
|
||||||
|
<AuthProvider>
|
||||||
<Router>
|
<Router>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<MainLayout />}>
|
<Route element={<MainLayout />}>
|
||||||
@@ -39,9 +42,11 @@ function App() {
|
|||||||
<Route path="/vods/:id/edit" element={<EditVod />} />
|
<Route path="/vods/:id/edit" element={<EditVod />} />
|
||||||
<Route path="/profile" element={<Profile />} />
|
<Route path="/profile" element={<Profile />} />
|
||||||
<Route path="/stream" element={<StreamControl />} />
|
<Route path="/stream" element={<StreamControl />} />
|
||||||
|
<Route path="*" element={<NotFound />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
|
</AuthProvider>
|
||||||
</ GoogleOAuthProvider>
|
</ GoogleOAuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
20
frontend/src/auth/AuthContext.ts
Normal file
20
frontend/src/auth/AuthContext.ts
Normal 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 };
|
||||||
46
frontend/src/auth/AuthProvider.tsx
Normal file
46
frontend/src/auth/AuthProvider.tsx
Normal 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;
|
||||||
14
frontend/src/auth/useAuth.ts
Normal file
14
frontend/src/auth/useAuth.ts
Normal 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 };
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
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";
|
import {config} from "../../config.ts";
|
||||||
|
|
||||||
@@ -19,12 +18,12 @@ type VideoCardProps = {
|
|||||||
itemLabel?: string,
|
itemLabel?: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackThumbnail = "../../../public/default_thumbnail.png";
|
const fallbackThumbnail = "public/default_thumbnail.png";
|
||||||
const API_URL = config.apiUrl;
|
const API_URL = 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);
|
||||||
@@ -37,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) => {
|
||||||
@@ -66,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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -64,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'}`);
|
||||||
|
|||||||
24
frontend/src/pages/NotFound.tsx
Normal file
24
frontend/src/pages/NotFound.tsx
Normal 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;
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(() => {});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) ->
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user