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
35 changed files with 1369 additions and 153 deletions

View File

@@ -12,7 +12,9 @@ post {
body:json {
{
"duration": 60.0
"duration": 60.0,
"title": "Hello",
"description": "my clip"
}
}

View File

@@ -12,8 +12,10 @@ post {
body:json {
{
"startTime": "2026-06-21T17:27:00Z",
"endTime": "2026-06-21T17:28:00Z"
"startTime": "2026-07-01T21:09:00Z",
"endTime": "2026-07-01T21:12:00Z",
"title": "Hello",
"description": "my clip"
}
}

View File

@@ -0,0 +1,16 @@
meta {
name: Delete VoD
type: http
seq: 4
}
delete {
url: {{base_url}}/vods/1
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -0,0 +1,16 @@
meta {
name: Get VoD Media
type: http
seq: 1
}
get {
url: {{base_url}}/vods/1/media
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -0,0 +1,15 @@
meta {
name: Get VoD Thumbnail
type: http
seq: 2
}
get {
url: {{base_url}}/vods/1/thumbnail
body: none
auth: inherit
}
settings {
encodeUrl: true
}

View File

@@ -0,0 +1,8 @@
meta {
name: Downloads
seq: 5
}
auth {
mode: inherit
}

View File

@@ -0,0 +1,16 @@
meta {
name: Get VoD
type: http
seq: 2
}
get {
url: {{base_url}}/vods/1
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -0,0 +1,11 @@
meta {
name: Get all vods
type: http
seq: 1
}
get {
url: {{base_url}}/vods
body: none
auth: inherit
}

View File

@@ -0,0 +1,23 @@
meta {
name: Update VoD
type: http
seq: 3
}
patch {
url: {{base_url}}/vods/1
body: json
auth: inherit
}
body:json {
{
"title": "My VoD",
"description": ""
}
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -0,0 +1,8 @@
meta {
name: VoDs
seq: 2
}
auth {
mode: inherit
}

View File

@@ -0,0 +1,8 @@
vars {
base_url: http://http://server.tail3cd897.ts.net:8087/api/v1
uuid:
}
vars:secret [
google_client_id,
google_secret
]

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

@@ -0,0 +1,108 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Check } from "lucide-react";
import { getVodById, patchVod } from "../utils/endpoints.ts";
import Box from "../components/Box.tsx";
import BlueButton from "../components/buttons/BlueButton.tsx";
const inputClass = "border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors";
const EditVod = () => {
const { id } = useParams();
const navigate = useNavigate();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!id) return;
getVodById(id)
.then((vod) => {
if (vod) {
setTitle(vod.title ?? "");
setDescription(vod.description ?? "");
}
})
.catch(() => setError("Failed to load vod."))
.finally(() => setLoading(false));
}, [id]);
const handleSave = async () => {
if (!id) return;
setSaving(true);
setError(null);
try {
await patchVod(Number(id), { title, description });
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save.");
} finally {
setSaving(false);
}
};
return (
<div className="px-8 py-10 max-w-2xl mx-auto">
<button
onClick={() => navigate(-1)}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 mb-6 transition-colors duration-150"
>
<ArrowLeft size={16} />
Back
</button>
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Edit VoD</h1>
{loading ? (
<div className="text-gray-400 text-sm">Loading...</div>
) : (
<Box className="p-6 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Title</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter title"
className={inputClass}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Enter description"
rows={4}
className={inputClass + " resize-none"}
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex items-center gap-3 pt-1">
<BlueButton onClick={handleSave} disabled={saving} className="w-28">
{saving ? "Saving…" : saved
? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span>
: "Save"}
</BlueButton>
<button
onClick={() => navigate(-1)}
className="text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
>
Cancel
</button>
</div>
</Box>
)}
</div>
);
};
export default EditVod;

View File

@@ -0,0 +1,60 @@
import VideoCard from "../components/video/VideoCard";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { getVods, deleteVod } from "../utils/endpoints";
import type { Vod } from "../utils/types";
const MyVods = () => {
const [vods, setVods] = useState<Vod[]>([]);
const [error, setError] = useState<null | string>(null);
const navigate = useNavigate();
useEffect(() => {
getVods()
.then((data) => setVods(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())))
.catch((err) => setError(err instanceof Error ? err.message : "Failed to load vods"));
}, []);
const handleDelete = async (id: number) => {
try {
await deleteVod(id);
setVods(prev => prev.filter(v => v.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete vod");
}
};
return (
<div className="p-6">
<h1 className="text-2xl font-semibold text-gray-900 mb-6">My VoDs</h1>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{vods.map((vod) => (
<VideoCard
key={vod.id}
id={vod.id}
title={vod.title}
duration={vod.duration}
createdAt={vod.createdAt}
mediaApiPath="/api/v1/vods"
playerPath="/vod"
itemLabel="vod"
onEdit={() => navigate(`/vods/${vod.id}/edit`)}
onDelete={() => handleDelete(vod.id)}
/>
))}
</div>
{vods.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<p className="text-base">No VoDs yet.</p>
<p className="text-sm mt-1">Your stream recordings will appear here.</p>
</div>
)}
{error && <p className="text-sm text-red-500 mt-4">{error}</p>}
</div>
);
};
export default MyVods;

View File

@@ -0,0 +1,310 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Lock, Maximize, Minimize, Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react";
import type { Vod } from "../utils/types";
import { AuthError, getVodById, getVodBlob } from "../utils/endpoints.ts";
import Box from "../components/Box.tsx";
import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts";
const VodPlayer = () => {
const { id } = useParams();
const navigate = useNavigate();
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const progressRef = useRef<HTMLDivElement>(null);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [videoUrl, setVideoUrl] = useState<string | undefined>(undefined);
const [error, setError] = useState<string | null>(null);
const [notAuthenticated, setNotAuthenticated] = useState(false);
const [vod, setVod] = useState<Vod | null>(null);
const [timeAgo, setTimeAgo] = useState("");
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [bufferedPct, setBufferedPct] = useState(0);
const [isWaiting, setIsWaiting] = useState(false);
const [volume, setVolume] = useState(1);
const [isMuted, setIsMuted] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [controlsVisible, setControlsVisible] = useState(true);
const [isDragging, setIsDragging] = useState(false);
useEffect(() => {
if (!id) { setError("VoD ID is required."); return; }
getVodBlob(id)
.then((blob) => setVideoUrl(URL.createObjectURL(blob)))
.catch((e) => { if (e instanceof AuthError) setNotAuthenticated(true); });
getVodById(id)
.then(setVod)
.catch((e) => {
if (e instanceof AuthError) setNotAuthenticated(true);
else setError("Failed to load vod details.");
});
}, [id]);
useEffect(() => {
if (!vod?.createdAt) return;
const update = () => setTimeAgo(dateToTimeAgo(stringToDate(vod.createdAt)));
update();
const interval = setInterval(update, 1000);
return () => clearInterval(interval);
}, [vod]);
const scheduleHide = useCallback(() => {
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
hideTimerRef.current = setTimeout(() => setControlsVisible(false), 3000);
}, []);
const showControls = useCallback(() => {
setControlsVisible(true);
scheduleHide();
}, [scheduleHide]);
useEffect(() => {
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onChange);
return () => document.removeEventListener('fullscreenchange', onChange);
}, []);
const toggleFullscreen = useCallback(() => {
if (!containerRef.current) return;
document.fullscreenElement
? document.exitFullscreen()
: containerRef.current.requestFullscreen();
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
const video = videoRef.current;
if (!video) return;
switch (e.code) {
case 'Space': case 'KeyK':
e.preventDefault();
video.paused ? video.play() : video.pause();
showControls();
break;
case 'ArrowLeft':
e.preventDefault();
video.currentTime = Math.max(0, video.currentTime - 5);
showControls();
break;
case 'ArrowRight':
e.preventDefault();
video.currentTime = Math.min(video.duration, video.currentTime + 5);
showControls();
break;
case 'KeyM':
video.muted = !video.muted;
setIsMuted(video.muted);
break;
case 'KeyF':
toggleFullscreen();
break;
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [showControls, toggleFullscreen]);
const seekTo = useCallback((clientX: number) => {
const rect = progressRef.current?.getBoundingClientRect();
const video = videoRef.current;
if (!rect || !video || !duration) return;
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
video.currentTime = ratio * duration;
setCurrentTime(ratio * duration);
}, [duration]);
useEffect(() => {
if (!isDragging) return;
const onMove = (e: MouseEvent) => seekTo(e.clientX);
const onUp = () => setIsDragging(false);
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
return () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
}, [isDragging, seekTo]);
const handleTimeUpdate = () => {
const video = videoRef.current;
if (!video) return;
setCurrentTime(video.currentTime);
if (video.buffered.length > 0) {
setBufferedPct((video.buffered.end(video.buffered.length - 1) / video.duration) * 100);
}
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = parseFloat(e.target.value);
setVolume(v);
setIsMuted(v === 0);
if (videoRef.current) {
videoRef.current.volume = v;
videoRef.current.muted = v === 0;
}
};
const toggleMute = () => {
const video = videoRef.current;
if (!video) return;
video.muted = !video.muted;
setIsMuted(video.muted);
};
const togglePlay = () => {
const video = videoRef.current;
if (!video) return;
video.paused ? video.play() : video.pause();
showControls();
};
const progressPct = duration > 0 ? (currentTime / duration) * 100 : 0;
const effectiveVolume = isMuted ? 0 : volume;
const VolumeIcon = effectiveVolume === 0 ? VolumeX : effectiveVolume < 0.5 ? Volume1 : Volume2;
if (notAuthenticated) {
return (
<div className="h-full flex flex-col items-center justify-center gap-4 text-center p-8">
<div className="w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center">
<Lock size={24} className="text-gray-400" />
</div>
<div>
<p className="text-lg font-semibold text-gray-900">Not authenticated</p>
<p className="text-sm text-gray-500 mt-1">You don't have access to this VoD.</p>
</div>
<button
onClick={() => navigate(-1)}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
>
<ArrowLeft size={16} />
Go back
</button>
</div>
);
}
return (
<div className="h-full flex flex-col p-4 gap-3">
<button
onClick={() => navigate(-1)}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 flex-shrink-0 transition-colors duration-150"
>
<ArrowLeft size={16} />
Back
</button>
<div
ref={containerRef}
className="relative bg-black rounded-xl overflow-hidden flex-1 min-h-0 select-none"
onMouseMove={showControls}
onMouseLeave={() => isPlaying && setControlsVisible(false)}
onClick={togglePlay}
style={{ cursor: controlsVisible ? 'default' : 'none' }}
>
<video
ref={videoRef}
className="w-full h-full object-contain"
src={videoUrl}
onPlay={() => { setIsPlaying(true); scheduleHide(); }}
onPause={() => {
setIsPlaying(false);
setControlsVisible(true);
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
}}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={() => { if (videoRef.current) setDuration(videoRef.current.duration); }}
onWaiting={() => setIsWaiting(true)}
onCanPlay={() => setIsWaiting(false)}
onError={(e) => setError(e.currentTarget.error?.message || "An error occurred.")}
/>
{isWaiting && videoUrl && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="w-11 h-11 rounded-full border-[3px] border-white/20 border-t-white animate-spin" />
</div>
)}
<div className={`absolute inset-0 flex flex-col justify-end transition-opacity duration-300 pointer-events-none ${controlsVisible ? 'opacity-100' : 'opacity-0'}`}>
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/10 to-transparent" />
<div
className="relative px-4 pb-4 pt-10 pointer-events-auto"
onClick={(e) => e.stopPropagation()}
>
<div
ref={progressRef}
className="relative h-1 rounded-full bg-white/25 cursor-pointer mb-3 group/prog hover:h-1.5 transition-[height] duration-150"
onMouseDown={(e) => { setIsDragging(true); seekTo(e.clientX); }}
>
<div className="absolute inset-y-0 left-0 bg-white/35 rounded-full" style={{ width: `${bufferedPct}%` }} />
<div className="absolute inset-y-0 left-0 bg-primary rounded-full" style={{ width: `${progressPct}%` }} />
<div
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow opacity-0 group-hover/prog:opacity-100 transition-opacity"
style={{ left: `calc(${progressPct}% - 6px)` }}
/>
</div>
<div className="flex items-center gap-3 text-white">
<button onClick={togglePlay} className="hover:text-white/70 transition-colors">
{isPlaying
? <Pause size={20} fill="currentColor" />
: <Play size={20} fill="currentColor" />}
</button>
<div className="flex items-center gap-1.5 group/vol">
<button onClick={toggleMute} className="hover:text-white/70 transition-colors">
<VolumeIcon size={18} />
</button>
<div className="overflow-hidden w-0 group-hover/vol:w-16 transition-[width] duration-200">
<input
type="range"
min={0} max={1} step={0.02}
value={effectiveVolume}
onChange={handleVolumeChange}
className="w-16 accent-white cursor-pointer"
/>
</div>
</div>
<span className="text-xs font-mono tabular-nums text-white/80">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
<div className="flex-1" />
<button onClick={toggleFullscreen} className="hover:text-white/70 transition-colors">
{isFullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
</button>
</div>
</div>
</div>
</div>
{error && <div className="text-red-500 text-sm mt-3">{error}</div>}
{!videoUrl && !error && (
<div className="text-gray-400 text-sm mt-3 flex items-center gap-2">
<div className="w-3 h-3 rounded-full border-2 border-gray-300 border-t-gray-500 animate-spin" />
Loading video...
</div>
)}
<Box className="p-4 flex-shrink-0">
<p className="text-xl font-semibold text-gray-900">{vod?.title || "(No Title)"}</p>
<p className="text-sm text-gray-500 mt-1">{timeAgo}</p>
</Box>
</div>
);
};
export default VodPlayer;

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);
}
}

View File

@@ -42,7 +42,11 @@ public class MediaController {
@PostMapping("/save")
public ResponseEntity<APIResponse<UUIDResponse>> save(
@RequestBody SaveSectionRequest saveSectionRequest) throws IOException {
Job job = mediaService.saveSection(saveSectionRequest.startTime(), saveSectionRequest.endTime());
Job job = mediaService.saveSection(
saveSectionRequest.startTime(),
saveSectionRequest.endTime(),
saveSectionRequest.title(),
saveSectionRequest.description());
return ResponseEntity.ok(new APIResponse<>(
SUCCESS,
"Section saving successfully started",
@@ -53,7 +57,11 @@ public class MediaController {
@PostMapping("/clip")
public ResponseEntity<APIResponse<UUIDResponse>> clip(
@RequestBody ClipSectionRequest clipSectionRequest) throws IOException {
Job job = mediaService.clip(clipSectionRequest.duration());
Job job = mediaService.clip(
clipSectionRequest.duration(),
clipSectionRequest.title(),
clipSectionRequest.description()
);
return ResponseEntity.ok(new APIResponse<>(
SUCCESS,
"Clipping successfully started",

View File

@@ -0,0 +1,95 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.controllers.dto.VodResponse;
import com.ddf.vodsystem.controllers.dto.VodUpdateRequest;
import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.entities.Vod;
import com.ddf.vodsystem.services.VodService;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/v1/vods")
public class VodController {
private final VodService vodService;
private static final String SUCCESS = "success";
private static final String FILENAME_HEADER = "inline; filename=\"%s\"";
public VodController(VodService vodService) {
this.vodService = vodService;
}
@GetMapping("")
public ResponseEntity<APIResponse<List<VodResponse>>> getVods() {
List<VodResponse> vods = vodService.getUserVods().stream()
.map(this::convertToDTO)
.toList();
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vods retrieved successfully", vods));
}
@GetMapping("/{id}")
public ResponseEntity<APIResponse<VodResponse>> getVodById(@PathVariable Long id) {
VodResponse vod = convertToDTO(vodService.getVodById(id));
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vod retrieved successfully", vod));
}
@PatchMapping("/{id}")
public ResponseEntity<APIResponse<VodResponse>> updateVod(@PathVariable Long id,
@RequestBody VodUpdateRequest updateFields) {
VodResponse vod = convertToDTO(vodService.updateVod(id, updateFields));
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vod updated successfully", vod));
}
@DeleteMapping("/{id}")
public ResponseEntity<APIResponse<String>> deleteVod(@PathVariable Long id) {
vodService.deleteVod(id);
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vod deleted successfully",
"Vod with ID " + id + " has been deleted"));
}
@GetMapping("/{id}/media")
public ResponseEntity<Resource> downloadVod(@PathVariable Long id) {
Resource resource = vodService.downloadVod(id);
if (resource == null || !resource.exists()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource);
}
@GetMapping("/{id}/thumbnail")
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) {
Resource resource = vodService.downloadThumbnail(id);
if (resource == null || !resource.exists()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource);
}
private VodResponse convertToDTO(Vod vod) {
return new VodResponse(
vod.getId(),
vod.getUser().getId(),
vod.getTitle(),
vod.getDescription(),
vod.getDuration(),
vod.getCreatedAt()
);
}
}

View File

@@ -3,5 +3,7 @@ package com.ddf.vodsystem.controllers.dto;
import jakarta.validation.constraints.NotNull;
public record ClipSectionRequest(
@NotNull float duration
@NotNull float duration,
String title,
String description
) {}

View File

@@ -5,4 +5,6 @@ import jakarta.validation.constraints.NotNull;
public record SaveSectionRequest(
@NotNull Instant startTime,
@NotNull Instant endTime) {}
@NotNull Instant endTime,
String title,
String description) {}

View File

@@ -0,0 +1,12 @@
package com.ddf.vodsystem.controllers.dto;
import java.time.Instant;
public record VodResponse(
Long id,
Long userId,
String title,
String description,
Float duration,
Instant createdAt
) {}

View File

@@ -0,0 +1,6 @@
package com.ddf.vodsystem.controllers.dto;
public record VodUpdateRequest(
String title,
String description
) {}

View File

@@ -0,0 +1,52 @@
package com.ddf.vodsystem.entities;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import lombok.Data;
import java.time.Instant;
@Entity
@Table(name = "vods")
@Data
public class Vod {
@Id
@Column(name = "id", nullable = false, unique = true)
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JsonIgnore
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(name = "title", nullable = false, length = 255)
private String title;
@Column(name = "description")
private String description;
@Column(name = "created_at")
private Instant createdAt;
@Column(name = "width", nullable = false)
private Integer width;
@Column(name = "height", nullable = false)
private Integer height;
@Column(name = "fps", nullable = false)
private Float fps;
@Column(name = "duration", nullable = false)
private Float duration;
@Column(name = "file_size", nullable = false)
private Float fileSize;
@Column(name = "video_path", nullable = false, length = 255)
private String videoPath;
@Column(name = "thumbnail_path", nullable = false, length = 255)
private String thumbnailPath;
}

View File

@@ -0,0 +1,7 @@
package com.ddf.vodsystem.exceptions;
public class VodNotFound extends RuntimeException {
public VodNotFound(String message) {
super(message);
}
}

View File

@@ -0,0 +1,16 @@
package com.ddf.vodsystem.repositories;
import com.ddf.vodsystem.entities.User;
import com.ddf.vodsystem.entities.Vod;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface VodRepository extends JpaRepository<Vod, Long>
{
@Query("SELECT v FROM Vod v WHERE v.user = ?1")
List<Vod> findByUser(User user);
}

View File

@@ -46,10 +46,10 @@ public class ClipService {
}
/**
* Retrieves all clips associated with the currently logged-in user.
* Returns all clips belonging to the currently authenticated user.
*
* @return a list of clips belonging to the authenticated user.
* @throws NotAuthenticated if the user is not authenticated.
* @return list of clips owned by the current user, in repository order
* @throws NotAuthenticated if no user session is present
*/
public List<Clip> getClipsByUser() {
Optional<User> user = userService.getLoggedInUser();
@@ -62,11 +62,12 @@ public class ClipService {
}
/**
* Retrieves a clip by its ID, ensuring the user is authenticated to access it.
* Returns the clip with the given ID, verifying ownership by the current user.
*
* @param id the ID of the clip to retrieve.
* @return an Optional containing the Clip if found and accessible, or empty if not found.
* @throws NotAuthenticated if the user is not authorized to access the clip.
* @param id the ID of the clip to retrieve
* @return an Optional containing the clip; never empty — throws instead of returning empty
* @throws ClipNotFound if no clip with {@code id} exists
* @throws NotAuthenticated if the current user does not own the clip
*/
public Optional<Clip> getClipById(Long id) {
Optional<Clip> clip = clipRepository.findById(id);
@@ -82,6 +83,17 @@ public class ClipService {
return clip;
}
/**
* Applies a partial update to an existing clip's metadata.
* <p>
* Only non-null fields in {@code newFields} are written; null fields are left unchanged.
*
* @param id the ID of the clip to update
* @param newFields fields to overwrite; any null field is ignored
* @return the updated clip as persisted in the database
* @throws ClipNotFound if no clip with {@code id} exists
* @throws NotAuthenticated if the current user does not own the clip
*/
public Clip updateClip(Long id, ClipUpdateRequest newFields) {
Optional<Clip> possibleClip = clipRepository.findById(id);
@@ -107,11 +119,12 @@ public class ClipService {
}
/**
* Deletes a clip by its ID, ensuring the user is authenticated to perform the deletion.
* Deletes a clip and its associated files from disk.
*
* @param id the ID of the clip to delete.
* @return true if the clip was successfully deleted, false if it was not found.
* @throws NotAuthenticated if the user is not authorized to delete the clip.
* @param id the ID of the clip to delete
* @return {@code true} on success; always throws rather than returning {@code false}
* @throws ClipNotFound if no clip with {@code id} exists
* @throws NotAuthenticated if the current user does not own the clip
*/
public boolean deleteClip(Long id) {
Optional<Clip> possibleClip = getClipById(id);
@@ -133,10 +146,11 @@ public class ClipService {
}
/**
* Checks if the currently logged-in user is authenticated to access the specified clip.
* Returns whether the current user owns the given clip.
*
* @param clip the clip to check access for.
* @return true if the user is authenticated for the clip, false otherwise.
* @param clip the clip to check ownership of
* @return {@code true} if the current user's ID matches the clip's owner; {@code false} if
* {@code clip} is null or no user session exists
*/
public boolean isAuthenticatedForClip(Clip clip) {
Optional<User> user = userService.getLoggedInUser();
@@ -146,6 +160,14 @@ public class ClipService {
return user.get().getId().equals(clip.getUser().getId());
}
/**
* Returns a streamable resource for the clip's video file.
*
* @param id the ID of the clip to download
* @return a {@link Resource} pointing to the clip's video file on disk
* @throws ClipNotFound if no clip with {@code id} exists, or the video file is missing on disk
* @throws NotAuthenticated if the current user does not own the clip
*/
public Resource downloadClip(Long id) {
Optional<Clip> possibleClip = getClipById(id);
@@ -169,6 +191,14 @@ public class ClipService {
return new FileSystemResource(file);
}
/**
* Returns a streamable resource for the clip's thumbnail image.
*
* @param id the ID of the clip whose thumbnail to download
* @return a {@link Resource} pointing to the thumbnail file on disk
* @throws ClipNotFound if no clip with {@code id} exists, or the thumbnail file is missing on disk
* @throws NotAuthenticated if the current user does not own the clip
*/
public Resource downloadThumbnail(Long id) {
Optional<Clip> possibleClip = getClipById(id);
@@ -193,32 +223,21 @@ public class ClipService {
}
/**
* Persists a clip to the database
* @param options ClipOptions object of the clip metadata to save to the database. All fields required except for title, description
* @param user User to save the clip to
* @param videoPath Path of the clip
* @param thumbnailPath Path of the thumbnail
* @return Clip object saved to the database
* Copies a processed clip file into permanent storage, generates its thumbnail, and saves
* the clip record to the database.
* <p>
* Thumbnail generation failure is non-fatal — the error is logged and the clip is saved
* without a thumbnail rather than aborting.
*
* @param title title to assign to the clip
* @param description description to assign to the clip
* @param user owner of the clip
* @param clipFile path to the processed video file (typically in the temp output directory)
* @param fileName filename used for both the permanent video file and the thumbnail
* (thumbnail is stored as {@code fileName + ".png"})
* @throws StorageException if copying the file to the clips directory fails
* @throws FFMPEGException if reading video metadata from the copied file fails
*/
public Clip saveClip(ClipOptions options,
User user,
Path videoPath,
Path thumbnailPath) {
Clip clip = new Clip();
clip.setUser(user);
clip.setTitle(options.getTitle() != null ? options.getTitle() : "Untitled Clip");
clip.setDescription(options.getDescription() != null ? options.getDescription() : "");
clip.setCreatedAt(Instant.now());
clip.setWidth(options.getWidth());
clip.setHeight(options.getHeight());
clip.setFps(options.getFps());
clip.setDuration(options.getDuration() - options.getStartPoint());
clip.setFileSize(options.getFileSize());
clip.setVideoPath(directoryService.relativisePath(videoPath.toAbsolutePath()).toString());
clip.setThumbnailPath(directoryService.relativisePath(thumbnailPath.toAbsolutePath()).toString());
return clipRepository.save(clip);
}
public void persistClip(String title,
String description,
User user,
@@ -261,6 +280,25 @@ public class ClipService {
logger.info("Clip created successfully with ID: {}", clip.getId());
}
private Clip saveClip(ClipOptions metadata,
User user,
Path videoPath,
Path thumbnailPath) {
Clip clip = new Clip();
clip.setUser(user);
clip.setTitle(metadata.getTitle() != null ? metadata.getTitle() : "Untitled Clip");
clip.setDescription(metadata.getDescription() != null ? metadata.getDescription() : "");
clip.setCreatedAt(Instant.now());
clip.setWidth(metadata.getWidth());
clip.setHeight(metadata.getHeight());
clip.setFps(metadata.getFps());
clip.setDuration(metadata.getDuration() - metadata.getStartPoint());
clip.setFileSize(metadata.getFileSize());
clip.setVideoPath(directoryService.relativisePath(videoPath.toAbsolutePath()).toString());
clip.setThumbnailPath(directoryService.relativisePath(thumbnailPath.toAbsolutePath()).toString());
return clipRepository.save(clip);
}
private void deleteClipFiles(Clip clip) {
File clipFile = new File(clip.getVideoPath());
File thumbnailFile = new File(clip.getThumbnailPath());

View File

@@ -16,6 +16,7 @@ import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Service
@@ -30,20 +31,36 @@ public class MediaService {
private static final float CLIP_MAX_LENGTH = 180;
private static final int HLS_SEGMENT_LENGTH = 3;
private final StreamActionsService streamActionsService;
private final VodService vodService;
public MediaService(JobRegistryService jobRegistryService,
DirectoryService directoryService,
CompressionService compressionService,
UserService userService,
ClipService clipService, StreamActionsService streamActionsService) {
ClipService clipService, StreamActionsService streamActionsService, VodService vodService) {
this.jobRegistryService = jobRegistryService;
this.directoryService = directoryService;
this.compressionService = compressionService;
this.userService = userService;
this.clipService = clipService;
this.streamActionsService = streamActionsService;
this.vodService = vodService;
}
/**
* Submits an asynchronous compression job for the uploaded video file.
* <p>
* Saves the upload to a temporary input path, then runs the compression pipeline
* in the background according to {@code clipOptions}. If a user session exists at
* call time, the output is also persisted as a clip when the job succeeds.
*
* @param file the uploaded video file to compress
* @param clipOptions trim window, resolution, fps, and metadata for the output clip
* @return the {@link Job} tracking the compression; state transitions to
* {@link JobState#SUCCEEDED} or {@link JobState#FAILED} asynchronously
* @throws IOException if saving the upload to the temporary directory fails
* @throws InterruptedException if the current thread is interrupted while setting up the job
*/
public Job compress(MultipartFile file, ClipOptions clipOptions) throws IOException, InterruptedException {
Job job = jobRegistryService.generateJob();
@@ -86,20 +103,29 @@ public class MediaService {
}
/**
* Saves a clip of the currently authenticated user's stream between two points in time.
* Saves a section of the currently authenticated user's live stream between two absolute timestamps.
* <p>
* Locates the recorded {@code .ts} segments overlapping the requested window, computes the
* trim offset into the first (partially-overlapping) segment and the total clip duration,
* then delegates the actual extraction to {@link StreamActionsService#saveSection}.
* Locates the recorded {@code .ts} HLS segments that overlap {@code [startTime, endTime)}, computes
* the trim offset into the first partially-overlapping segment, then delegates extraction and muxing
* to {@link StreamActionsService#saveSection}. On success, the output is persisted as a VoD via
* {@link VodService#persist}.
*
* @param startTime the inclusive start of the section to save; must be before {@code endTime}
* @param endTime the exclusive end of the section to save
* @param startTime inclusive start of the section to save; must be before {@code endTime}
* @param endTime exclusive end of the section to save
* @param title title for the saved VoD; defaults to the current timestamp if {@code null}
* @param description description for the saved VoD; defaults to an empty string if {@code null}
* @return the {@link Job} tracking the save; state transitions to
* {@link JobState#SUCCEEDED} or {@link JobState#FAILED} asynchronously
* @throws IllegalArgumentException if {@code startTime} is not before {@code endTime},
* or if no stream segments exist in the given range
* @throws NotAuthenticated if no user is currently authenticated
* @throws IOException if reading the stream directory or its segments fails
*/
public Job saveSection(Instant startTime, Instant endTime) throws IOException {
public Job saveSection(
Instant startTime,
Instant endTime,
String title,
String description) throws IOException {
if (startTime.isAfter(endTime)) {
throw new IllegalArgumentException("Start time must be before end time");
}
@@ -108,97 +134,94 @@ public class MediaService {
.orElseThrow(() -> new NotAuthenticated("User is not authenticated"));
Job job = jobRegistryService.generateJob();
Path streamDirectory = directoryService.getStreamDir(user.getStreamKey());
List<Path> fileSegments = getSegmentsInRange(streamDirectory, startTime, endTime);
if (fileSegments.isEmpty()) {
throw new IllegalArgumentException("No stream segments found in the given time range");
}
long firstSegmentMs = parseTimestampMs(fileSegments.getFirst());
float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstSegmentMs) / 1000f);
SegmentContext ctx = resolveSegments(user, startTime, endTime);
float duration = (endTime.toEpochMilli() - startTime.toEpochMilli()) / 1000f;
job.setState(JobState.PROCESSING);
Path outputFile = directoryService.getVodsDir(user.getId()).resolve(UUID.randomUUID() + ".mp4");
streamActionsService.saveSection(
fileSegments,
trimOffset,
duration,
outputFile,
job.getProgressTracker()
).thenRun(() -> {
job.setDownload(outputFile);
job.setState(JobState.SUCCEEDED);
logger.info("Stream save of job {} succeeded", job.getUuid());
}).exceptionally(ex -> {
job.setState(JobState.FAILED);
job.setErrorOutput(ex.getMessage());
logger.error("Stream section save job with UUID {} failed due to: {}", job.getUuid(), ex.getMessage());
return null;
});
return job;
return dispatchSaveJob(job, ctx, duration, outputFile, () ->
vodService.persist(
title == null ? Instant.now().toString() : title,
description == null ? "" : description,
user,
outputFile,
outputFile.getFileName().toString()
)
);
}
/**
* Saves a clip of the last {@code duration} seconds of the currently authenticated user's stream.
* Saves the last {@code duration} seconds of the currently authenticated user's live stream as a clip.
* <p>
* Computes a time window ending at the current instant and beginning {@code duration} seconds
* earlier (with millisecond precision), then delegates to {@link #saveSection(Instant, Instant)}.
* Computes a window of {@code [now - duration, now)} with millisecond precision, locates the
* overlapping HLS {@code .ts} segments, and delegates extraction and muxing to
* {@link StreamActionsService#saveSection}. On success, the output is persisted as a clip via
* {@link ClipService#persistClip}.
*
* @param duration the length of the clip in seconds, measured back from now; must be greater
* than {@code 0} and no greater than {@link #CLIP_MAX_LENGTH}. Fractional values
* are supported and resolved to the millisecond.
* @throws IllegalArgumentException if {@code duration} is not in the range {@code (0, CLIP_MAX_LENGTH]},
* @param duration length of the clip in seconds measured back from now; must be in
* {@code (0, CLIP_MAX_LENGTH]}. Fractional values are supported.
* @param title title for the saved clip; defaults to the current timestamp if {@code null}
* @param description description for the saved clip; defaults to an empty string if {@code null}
* @return the {@link Job} tracking the save; state transitions to
* {@link JobState#SUCCEEDED} or {@link JobState#FAILED} asynchronously
* @throws IllegalArgumentException if {@code duration} is not in {@code (0, CLIP_MAX_LENGTH]},
* or if no stream segments exist in the computed window
* @throws NotAuthenticated if no user is currently authenticated
* @throws IOException if reading the stream directory or its segments fails
*/
public Job clip(float duration) throws IOException {
public Job clip(float duration, String title, String description) throws IOException {
if (duration <= 0 || duration > CLIP_MAX_LENGTH) {
throw new IllegalArgumentException("Clip length must be between 0 and " + CLIP_MAX_LENGTH + " seconds");
}
// minusSeconds() is possible, but only does integer seconds, not float
Instant endTime = Instant.now();
// minusSeconds() only does integer seconds, not float
Instant startTime = endTime.minus(Duration.ofMillis((long) (duration * 1000)));
User user = userService.getLoggedInUser()
.orElseThrow(() -> new NotAuthenticated("User is not authenticated"));
Job job = jobRegistryService.generateJob();
Path streamDirectory = directoryService.getStreamDir(user.getStreamKey());
List<Path> fileSegments = getSegmentsInRange(streamDirectory, startTime, endTime);
SegmentContext ctx = resolveSegments(user, startTime, endTime);
Path outputFile = directoryService.getTempOutputDir().resolve(UUID.randomUUID() + ".mp4");
if (fileSegments.isEmpty()) {
return dispatchSaveJob(job, ctx, duration, outputFile, () ->
clipService.persistClip(
title == null ? Instant.now().toString() : title,
description == null ? "" : description,
user,
outputFile,
outputFile.getFileName().toString()
)
);
}
private record SegmentContext(List<Path> segments, float trimOffset) {}
private SegmentContext resolveSegments(User user, Instant startTime, Instant endTime) throws IOException {
Path streamDir = directoryService.getStreamDir(user.getStreamKey());
List<Path> segments = getSegmentsInRange(streamDir, startTime, endTime);
if (segments.isEmpty()) {
throw new IllegalArgumentException("No stream segments found in the given time range");
}
long firstMs = parseTimestampMs(segments.getFirst());
float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstMs) / 1000f);
return new SegmentContext(segments, trimOffset);
}
long firstSegmentMs = parseTimestampMs(fileSegments.getFirst());
float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstSegmentMs) / 1000f);
Path outputFile = directoryService.getTempOutputDir().resolve(UUID.randomUUID() + ".mp4");
streamActionsService.saveSection(fileSegments, trimOffset, duration, outputFile, job.getProgressTracker())
private Job dispatchSaveJob(Job job, SegmentContext ctx, float duration, Path outputFile, Runnable onSuccess) {
job.setState(JobState.PROCESSING);
streamActionsService.saveSection(ctx.segments(), ctx.trimOffset(), duration, outputFile, job.getProgressTracker())
.thenRun(() -> {
job.setDownload(outputFile);
job.setState(JobState.SUCCEEDED);
logger.info("Stream clip of job {} succeeded", job.getUuid());
clipService.persistClip(
Instant.now().toString(),
"",
user,
outputFile,
outputFile.getFileName().toString()
);
}
).exceptionally(ex -> {
logger.info("Stream save job {} succeeded", job.getUuid());
onSuccess.run();
}).exceptionally(ex -> {
job.setState(JobState.FAILED);
job.setErrorOutput(ex.getMessage());
logger.error("Clip save job with UUID {} failed due to: {}", job.getUuid(), ex.getMessage());
logger.error("Stream save job {} failed: {}", job.getUuid(), ex.getMessage());
return null;
});
return job;
}
@@ -214,7 +237,7 @@ public class MediaService {
return segMs < endMs && (segMs + HLS_SEGMENT_LENGTH * 1000) > startMs;
})
.sorted(Comparator.comparingLong(this::parseTimestampMs))
.toList();
.collect(Collectors.toList());
}
}

View File

@@ -0,0 +1,263 @@
package com.ddf.vodsystem.services;
import com.ddf.vodsystem.controllers.dto.VodUpdateRequest;
import com.ddf.vodsystem.dto.ClipOptions;
import com.ddf.vodsystem.entities.User;
import com.ddf.vodsystem.entities.Vod;
import com.ddf.vodsystem.exceptions.*;
import com.ddf.vodsystem.repositories.VodRepository;
import com.ddf.vodsystem.services.media.MetadataService;
import com.ddf.vodsystem.services.media.ThumbnailService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
@Service
public class VodService {
private static final Logger logger = LoggerFactory.getLogger(VodService.class);
private final VodRepository vodRepository;
private final DirectoryService directoryService;
private final MetadataService metadataService;
private final ThumbnailService thumbnailService;
private final UserService userService;
public VodService(VodRepository vodRepository,
DirectoryService directoryService,
MetadataService metadataService,
ThumbnailService thumbnailService,
UserService userService) {
this.vodRepository = vodRepository;
this.directoryService = directoryService;
this.metadataService = metadataService;
this.thumbnailService = thumbnailService;
this.userService = userService;
}
/**
* Returns the VoD with the given ID, verifying ownership by the current user.
*
* @param id the ID of the VoD to retrieve
* @return the matching {@link Vod} entity
* @throws VodNotFound if no VoD with {@code id} exists
* @throws NotAuthenticated if the current user does not own the VoD
*/
public Vod getVodById(Long id) {
Optional<Vod> vod = vodRepository.findById(id);
if (vod.isEmpty()) {
throw new VodNotFound("Vod ID " + id + " does not exist");
}
if (!isAuthenticatedForVod(vod.get())) {
throw new NotAuthenticated("User not authenticated");
}
return vod.get();
}
/**
* Returns all VoDs belonging to the currently authenticated user.
*
* @return list of VoDs owned by the current user, in repository order
* @throws NotAuthenticated if no user session is present
*/
public List<Vod> getUserVods() {
Optional<User> user = userService.getLoggedInUser();
if (user.isEmpty()) {
throw new NotAuthenticated("User must be logged in to retrieve vods");
}
return vodRepository.findByUser(user.get());
}
/**
* Applies a partial update to an existing VoD's metadata.
* <p>
* Only non-null fields in {@code newFields} are written; null fields are left unchanged.
*
* @param id the ID of the VoD to update
* @param newFields fields to overwrite; any null field is ignored
* @return the updated VoD as persisted in the database
* @throws VodNotFound if no VoD with {@code id} exists
* @throws NotAuthenticated if the current user does not own the VoD
*/
public Vod updateVod(Long id, VodUpdateRequest newFields) {
Vod vod = getVodById(id);
if (newFields.title() != null) {
vod.setTitle(newFields.title());
}
if (newFields.description() != null) {
vod.setDescription(newFields.description());
}
return vodRepository.saveAndFlush(vod);
}
/**
* Deletes a VoD and its associated files from disk.
*
* @param id the ID of the VoD to delete
* @return {@code true} on success; always throws rather than returning {@code false}
* @throws VodNotFound if no VoD with {@code id} exists
* @throws NotAuthenticated if the current user does not own the VoD
*/
public boolean deleteVod(Long id) {
Vod vod = getVodById(id);
deleteVodFiles(vod);
vodRepository.delete(vod);
logger.info("Vod with ID {} deleted successfully", id);
return true;
}
/**
* Returns a streamable resource for the VoD's video file.
*
* @param id the ID of the VoD to download
* @return a {@link Resource} pointing to the VoD's video file on disk
* @throws VodNotFound if no VoD with {@code id} exists, or the video file is missing on disk
* @throws NotAuthenticated if the current user does not own the VoD
*/
public Resource downloadVod(Long id) {
Vod vod = getVodById(id);
Path file = directoryService.resolvePath(vod.getVideoPath());
if (!Files.exists(file)) {
throw new VodNotFound("Vod file not found for ID: " + id);
}
return new FileSystemResource(file);
}
/**
* Returns a streamable resource for the VoD's thumbnail image.
*
* @param id the ID of the VoD whose thumbnail to download
* @return a {@link Resource} pointing to the thumbnail file on disk
* @throws VodNotFound if no VoD with {@code id} exists, or the thumbnail file is missing on disk
* @throws NotAuthenticated if the current user does not own the VoD
*/
public Resource downloadThumbnail(Long id) {
Vod vod = getVodById(id);
Path file = directoryService.resolvePath(vod.getThumbnailPath());
if (!Files.exists(file)) {
throw new VodNotFound("Thumbnail file not found for vod ID: " + id);
}
return new FileSystemResource(file);
}
/**
* Copies a processed VoD file into permanent storage, generates its thumbnail, and saves
* the VoD record to the database.
* <p>
* Thumbnail generation failure is non-fatal — the error is logged and the VoD is saved
* without a thumbnail rather than aborting.
*
* @param title title to assign to the VoD
* @param description description to assign to the VoD
* @param user owner of the VoD
* @param vodFile path to the processed video file to copy into permanent storage
* @param fileName filename used for both the permanent video file and the thumbnail
* (thumbnail is stored as {@code fileName + ".png"})
* @throws StorageException if copying the file to the VoDs directory fails
* @throws FFMPEGException if reading video metadata from the copied file fails
*/
public void persist(String title,
String description,
User user,
Path vodFile,
String fileName) {
Path newVodFile;
Path thumbnailFile;
try {
newVodFile = directoryService.getVodsDir(user.getId()).resolve(fileName);
thumbnailFile = directoryService.getThumbnailsDir(user.getId()).resolve(fileName + ".png");
directoryService.copyFile(vodFile, newVodFile);
} catch (IOException e) {
throw new StorageException("Failed to move vod from temporary directory to output directory", e);
}
ClipOptions vodMetadata;
try {
vodMetadata = metadataService.getVideoMetadata(newVodFile).get();
} catch (InterruptedException | ExecutionException e) {
Thread.currentThread().interrupt();
throw new FFMPEGException("Error retrieving video metadata for vod: " + e.getMessage());
}
try {
thumbnailService.createThumbnail(newVodFile, thumbnailFile, 0.0f);
} catch (InterruptedException e) {
logger.error("Thumbnail generation interrupted for user: {}", user.getId(), e);
Thread.currentThread().interrupt();
} catch (IOException e) {
logger.error("Error generating thumbnail for user: {}", user.getId(), e);
}
vodMetadata.setTitle(title);
vodMetadata.setDescription(description);
Vod vod = saveToDb(vodMetadata, user, newVodFile, thumbnailFile);
logger.info("Vod created successfully with ID: {}", vod.getId());
}
private Vod saveToDb(ClipOptions options,
User user,
Path videoPath,
Path thumbnailPath) {
Vod vod = new Vod();
vod.setUser(user);
vod.setTitle(options.getTitle() != null ? options.getTitle() : "Untitled VoD");
vod.setDescription(options.getDescription() != null ? options.getDescription() : "");
vod.setCreatedAt(Instant.now());
vod.setWidth(options.getWidth());
vod.setHeight(options.getHeight());
vod.setFps(options.getFps());
vod.setDuration(options.getDuration());
vod.setFileSize(options.getFileSize());
vod.setVideoPath(directoryService.relativisePath(videoPath.toAbsolutePath()).toString());
vod.setThumbnailPath(directoryService.relativisePath(thumbnailPath.toAbsolutePath()).toString());
return vodRepository.save(vod);
}
private void deleteVodFiles(Vod vod) {
File vodFile = new File(vod.getVideoPath());
File thumbnailFile = new File(vod.getThumbnailPath());
try {
Files.deleteIfExists(vodFile.toPath());
Files.deleteIfExists(thumbnailFile.toPath());
} catch (IOException e) {
logger.error("Could not delete vod files for vod ID: {}", vod.getId());
}
}
private boolean isAuthenticatedForVod(Vod vod) {
Optional<User> user = userService.getLoggedInUser();
if (vod == null || user.isEmpty()) {
return false;
}
return user.get().getId().equals(vod.getUser().getId());
}
}

View File

@@ -6,7 +6,6 @@ import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.CompletableFuture;
@@ -76,13 +75,9 @@ public class StreamActionsService {
progressTracker.markComplete();
return CompletableFuture.completedFuture(outputFile);
} catch (IOException e) {
} catch (Exception e) {
logger.error("Failed to save section to '{}'", outputFile, e);
return CompletableFuture.failedFuture(e);
} catch (InterruptedException e) {
logger.error("Failed to save section");
Thread.currentThread().interrupt();
return CompletableFuture.failedFuture(e);
}
}
}

View File

@@ -1,25 +0,0 @@
INSERT INTO users ( google_id, username, email, name)
VALUES
('google-uid-001', 'alice', 'alice@example.com', 'Alice Example'),
( 'google-uid-002', 'bob', 'bob@example.com', 'Bob Example'),
('google-uid-003', 'carol', 'carol@example.com', 'Carol Example'),
( 'google-uid-004', 'wizard42', 'gandalf@middle.earth', 'Gandalf the Grey'),
( 'google-uid-005', 'catnap', 'whiskers@meowmail.com', 'Sir Whiskers McFluff'),
( 'google-uid-006', 'robotron', 'bender@futurama.tv', 'Bender Rodriguez'),
('google-uid-007', 'unicorn', 'sparkle@rainbow.com', 'Princess Sparklehoof'),
( 'google-uid-008', 'pirate', 'blackbeard@seas.com', 'Edward Teach'),
( 'google-uid-009', 'detective', 'holmes@bakerstreet.uk', 'Sherlock Holmes'),
( 'google-uid-010', 'timey', 'docbrown@delorean.net', 'Dr. Emmett Brown');
INSERT INTO clips (user_id, title, description, width, height, fps, duration, file_size, video_path)
VALUES
(4, 'Fireworks Over Hobbiton', 'A magical display of fireworks by Gandalf.', 1920, 1080, 30, 120, 104857600, '/videos/fireworks_hobbiton.mp4'),
(5, 'Catnap Chronicles', 'Sir Whiskers McFluff naps in 12 different positions.', 1280, 720, 24, 60, 52428800, '/videos/catnap_chronicles.mp4'),
(6, 'Bite My Shiny Metal...', 'Bender shows off his new upgrades.', 1920, 1080, 60, 45, 73400320, '/videos/bender_upgrades.mp4'),
(7, 'Rainbow Dash', 'Princess Sparklehoof gallops across a double rainbow.', 1920, 1080, 30, 90, 67108864, '/videos/rainbow_dash.mp4'),
( 8, 'Pirate Karaoke Night', 'Blackbeard sings sea shanties with his crew.', 1280, 720, 25, 180, 157286400, '/videos/pirate_karaoke.mp4'),
( 9, 'The Case of the Missing Sandwich', 'Sherlock Holmes investigates a lunchtime mystery.', 1920, 1080, 30, 75, 50331648, '/videos/missing_sandwich.mp4'),
( 10, '88 Miles Per Hour', 'Doc Brown demonstrates time travel with style.', 1920, 1080, 60, 30, 41943040, '/videos/88mph.mp4'),
( 1, 'Alice in Videoland', 'Alice explores a surreal digital wonderland.', 1280, 720, 30, 150, 94371840, '/videos/alice_videoland.mp4'),
( 2, 'Bob''s Building Bonanza', 'Bob constructs a house out of cheese.', 1920, 1080, 24, 200, 209715200, '/videos/bob_cheesehouse.mp4'),
( 3, 'Carol''s Coding Catastrophe', 'Carol debugs a spaghetti codebase.', 1280, 720, 30, 100, 73400320, '/videos/carol_coding.mp4');

View File

@@ -39,3 +39,19 @@ CREATE TABLE IF NOT EXISTS clips (
thumbnail_path VARCHAR(255) NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS vods (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
width INTEGER NOT NULL,
height INTEGER NOT NULL,
fps FLOAT NOT NULL,
duration FLOAT NOT NULL,
file_size FLOAT NOT NULL,
video_path VARCHAR(255) NOT NULL,
thumbnail_path VARCHAR(255) NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);