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

View File

@@ -1,6 +1,6 @@
import clsx from "clsx";
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";
type props = {
@@ -31,6 +31,7 @@ const Sidebar = ({user, className}: props) => {
<Divider />
<div className="flex flex-col gap-0.5">
<SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" />
<SidebarButton url="/my-vods" logo={<Video size={18}/>} label="VoDs" />
</div>
</>
)}

View File

@@ -13,17 +13,21 @@ type VideoCardProps = {
onEdit?: () => void,
onDelete?: () => void,
className?: string,
mediaApiPath?: string,
playerPath?: string,
itemLabel?: string,
}
const fallbackThumbnail = "../../../public/default_thumbnail.png";
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 [thumbnailAvailable, setThumbnailAvailable] = useState(true);
const [menuOpen, setMenuOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const thumbnailUrl = `${API_URL}${mediaApiPath}/${id}/thumbnail`;
useEffect(() => {
const interval = setInterval(() => {
@@ -33,10 +37,10 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
}, [createdAt]);
useEffect(() => {
isThumbnailAvailable(id)
isThumbnailAvailable(thumbnailUrl)
.then(setThumbnailAvailable)
.catch(() => setThumbnailAvailable(false));
}, []);
}, [thumbnailUrl]);
useEffect(() => {
if (!menuOpen) return;
@@ -57,11 +61,11 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
};
return (
<Link to={"/video/" + id}>
<Link to={`${playerPath}/${id}`}>
<div className={clsx("flex flex-col group cursor-pointer", className)}>
<div className="relative overflow-hidden rounded-lg">
<img
src={thumbnailAvailable ? API_URL + `/api/v1/clips/${id}/thumbnail` : fallbackThumbnail}
src={thumbnailAvailable ? thumbnailUrl : fallbackThumbnail}
alt="Video Thumbnail"
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 && (
<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">
<button
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;
@@ -144,6 +144,69 @@ const deleteClip = async (id: number): Promise<void> => {
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 response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' });
if (!response.ok) return false;
@@ -151,8 +214,8 @@ const getStreamStatus = async (): Promise<boolean> => {
return result.status === 'success' && result.data?.isStreaming === true;
};
const isThumbnailAvailable = async (id: number): Promise<boolean> => {
const response = await fetch(API_URL + `/api/v1/clips/${id}/thumbnail`, { credentials: 'include' });
const isThumbnailAvailable = async (thumbnailUrl: string): Promise<boolean> => {
const response = await fetch(thumbnailUrl, { credentials: 'include' });
return response.ok;
};
@@ -167,6 +230,11 @@ export {
getVideoBlob,
patchClip,
deleteClip,
getVods,
getVodById,
getVodBlob,
patchVod,
deleteVod,
getStreamStatus,
isThumbnailAvailable,
};

View File

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

View File

@@ -94,4 +94,18 @@ public class GlobalExceptionHandler {
APIResponse<Void> response = new APIResponse<>(ERROR, "Could not process file", null);
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);
}
}