feat(vods): full vod support on backend and frontend #5

Merged
dylan merged 9 commits from feat/full-vod-support into master 2026-07-01 22:32:39 +01:00
6 changed files with 113 additions and 10 deletions
Showing only changes of commit 48ff0eb8e5 - Show all commits

View File

@@ -6,8 +6,11 @@ import ClipEdit from './pages/ClipEdit';
import Home from './pages/Home'; import Home from './pages/Home';
import {useEffect} from "react"; import {useEffect} from "react";
import MyClips from './pages/MyClips'; import MyClips from './pages/MyClips';
import MyVods from './pages/MyVods';
import VideoPlayer from "./pages/VideoPlayer.tsx"; import VideoPlayer from "./pages/VideoPlayer.tsx";
import VodPlayer from "./pages/VodPlayer.tsx";
import EditClip from "./pages/EditClip.tsx"; import EditClip from "./pages/EditClip.tsx";
import EditVod from "./pages/EditVod.tsx";
import Profile from "./pages/Profile.tsx"; import Profile from "./pages/Profile.tsx";
import { GoogleOAuthProvider } from '@react-oauth/google'; import { GoogleOAuthProvider } from '@react-oauth/google';
@@ -26,8 +29,11 @@ function App() {
<Route path="/create" element={<ClipUpload />} /> <Route path="/create" element={<ClipUpload />} />
<Route path="/create/:id" element={<ClipEdit />} /> <Route path="/create/:id" element={<ClipEdit />} />
<Route path="/my-clips" element={<MyClips />} /> <Route path="/my-clips" element={<MyClips />} />
<Route path="/my-vods" element={<MyVods />} />
<Route path="/video/:id" element={<VideoPlayer />} /> <Route path="/video/:id" element={<VideoPlayer />} />
<Route path="/vod/:id" element={<VodPlayer />} />
<Route path="/clips/:id/edit" element={<EditClip />} /> <Route path="/clips/:id/edit" element={<EditClip />} />
<Route path="/vods/:id/edit" element={<EditVod />} />
<Route path="/profile" element={<Profile />} /> <Route path="/profile" element={<Profile />} />
</Route> </Route>
</Routes> </Routes>

View File

@@ -1,6 +1,6 @@
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 } from 'lucide-react'; import { Plus, Film, Home, User as UserIcon, Video } from 'lucide-react';
import type {User} from "../utils/types.ts"; import type {User} from "../utils/types.ts";
type props = { type props = {
@@ -31,6 +31,7 @@ const Sidebar = ({user, className}: props) => {
<Divider /> <Divider />
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" /> <SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" />
<SidebarButton url="/my-vods" logo={<Video size={18}/>} label="VoDs" />
</div> </div>
</> </>
)} )}

View File

@@ -13,17 +13,21 @@ type VideoCardProps = {
onEdit?: () => void, onEdit?: () => void,
onDelete?: () => void, onDelete?: () => void,
className?: string, className?: string,
mediaApiPath?: string,
playerPath?: string,
itemLabel?: string,
} }
const fallbackThumbnail = "../../../public/default_thumbnail.png"; const fallbackThumbnail = "../../../public/default_thumbnail.png";
const API_URL = import.meta.env.VITE_API_URL; const API_URL = import.meta.env.VITE_API_URL;
const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className }: 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 [thumbnailAvailable, setThumbnailAvailable] = useState(true);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const thumbnailUrl = `${API_URL}${mediaApiPath}/${id}/thumbnail`;
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
@@ -33,10 +37,10 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
}, [createdAt]); }, [createdAt]);
useEffect(() => { useEffect(() => {
isThumbnailAvailable(id) isThumbnailAvailable(thumbnailUrl)
.then(setThumbnailAvailable) .then(setThumbnailAvailable)
.catch(() => setThumbnailAvailable(false)); .catch(() => setThumbnailAvailable(false));
}, []); }, [thumbnailUrl]);
useEffect(() => { useEffect(() => {
if (!menuOpen) return; if (!menuOpen) return;
@@ -57,11 +61,11 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
}; };
return ( return (
<Link to={"/video/" + id}> <Link to={`${playerPath}/${id}`}>
<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 ? API_URL + `/api/v1/clips/${id}/thumbnail` : fallbackThumbnail} src={thumbnailAvailable ? thumbnailUrl : fallbackThumbnail}
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"
/> />
@@ -105,7 +109,7 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
</>)} </>)}
{confirmDelete && ( {confirmDelete && (
<div className="px-3 py-2"> <div className="px-3 py-2">
<p className="text-xs text-gray-600 mb-2">Delete this clip?</p> <p className="text-xs text-gray-600 mb-2">Delete this {itemLabel}?</p>
<div className="flex gap-1.5"> <div className="flex gap-1.5">
<button <button
onClick={(e) => stopAndRun(e, onDelete!)} onClick={(e) => stopAndRun(e, onDelete!)}

View File

@@ -1,4 +1,4 @@
import type { VideoMetadata, APIResponse, User, Clip, JobResponse } from "./types.ts"; import type { VideoMetadata, APIResponse, User, Clip, Vod, JobResponse } from "./types.ts";
const API_URL = import.meta.env.VITE_API_URL; const API_URL = import.meta.env.VITE_API_URL;
@@ -144,6 +144,69 @@ const deleteClip = async (id: number): Promise<void> => {
if (result.status === 'error') throw new Error(`Failed to delete clip: ${result.message}`); if (result.status === 'error') throw new Error(`Failed to delete clip: ${result.message}`);
}; };
const getVods = async (): Promise<Vod[]> => {
const response = await fetch(API_URL + '/api/v1/vods', { credentials: 'include' });
if (!response.ok) {
const errorResult: APIResponse = await response.json();
throw new Error(`Failed to fetch vods: ${errorResult.message}`);
}
const result: APIResponse = await response.json();
return result.data;
};
const getVodById = async (id: string): Promise<Vod | null> => {
const response = await fetch(API_URL + `/api/v1/vods/${id}`, { credentials: 'include' });
if (!response.ok) {
if (response.status === 401 || response.status === 403) throw new AuthError();
throw new Error(`Failed to fetch vod: ${response.status}`);
}
const result: APIResponse = await response.json();
return result.data;
};
const getVodBlob = async (id: string): Promise<Blob> => {
const response = await fetch(API_URL + `/api/v1/vods/${id}/media`, { credentials: 'include' });
if (!response.ok) {
if (response.status === 401 || response.status === 403) throw new AuthError();
throw new Error(`Failed to fetch vod ${id}: ${response.status}`);
}
return response.blob();
};
const patchVod = async (id: number, data: { title?: string; description?: string }): Promise<Vod> => {
const response = await fetch(API_URL + `/api/v1/vods/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data),
});
if (!response.ok) throw new Error(`Failed to update vod: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to update vod: ${result.message}`);
return result.data;
};
const deleteVod = async (id: number): Promise<void> => {
const response = await fetch(API_URL + `/api/v1/vods/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) throw new Error(`Failed to delete vod: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to delete vod: ${result.message}`);
};
const getStreamStatus = async (): Promise<boolean> => { const getStreamStatus = async (): Promise<boolean> => {
const response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' }); const response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' });
if (!response.ok) return false; if (!response.ok) return false;
@@ -151,8 +214,8 @@ const getStreamStatus = async (): Promise<boolean> => {
return result.status === 'success' && result.data?.isStreaming === true; return result.status === 'success' && result.data?.isStreaming === true;
}; };
const isThumbnailAvailable = async (id: number): Promise<boolean> => { const isThumbnailAvailable = async (thumbnailUrl: string): Promise<boolean> => {
const response = await fetch(API_URL + `/api/v1/clips/${id}/thumbnail`, { credentials: 'include' }); const response = await fetch(thumbnailUrl, { credentials: 'include' });
return response.ok; return response.ok;
}; };
@@ -167,6 +230,11 @@ export {
getVideoBlob, getVideoBlob,
patchClip, patchClip,
deleteClip, deleteClip,
getVods,
getVodById,
getVodBlob,
patchVod,
deleteVod,
getStreamStatus, getStreamStatus,
isThumbnailAvailable, isThumbnailAvailable,
}; };

View File

@@ -33,6 +33,15 @@ type Clip = {
createdAt: string, createdAt: string,
} }
type Vod = {
id: number,
userId: number,
title: string,
description: string,
duration: number,
createdAt: string,
}
type JobResponse = { type JobResponse = {
uuid: string, uuid: string,
progress: number, progress: number,
@@ -47,5 +56,6 @@ export type {
VideoMetadata, VideoMetadata,
User, User,
Clip, Clip,
Vod,
JobResponse JobResponse
} }

View File

@@ -94,4 +94,18 @@ public class GlobalExceptionHandler {
APIResponse<Void> response = new APIResponse<>(ERROR, "Could not process file", null); APIResponse<Void> response = new APIResponse<>(ERROR, "Could not process file", null);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
} }
@ExceptionHandler(ClipNotFound.class)
public ResponseEntity<APIResponse<Void>> handleClipNotFound(ClipNotFound ex) {
logger.error("ClipNotFound: {}", ex.getMessage());
APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}
@ExceptionHandler(VodNotFound.class)
public ResponseEntity<APIResponse<Void>> handleVodNotFound(VodNotFound ex) {
logger.error("VodNotFound: {}", ex.getMessage());
APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}
} }