perf(api): add HTTP range requests on media sources (#9)
Reviewed-on: https://gitea.vodsystem.ddns.net/dylan/vod-system/pulls/9
This commit was merged in pull request #9.
This commit is contained in:
@@ -94,6 +94,8 @@ docker compose -f docker-compose.dev.yml up
|
|||||||
|
|
||||||
Then run the backend and frontend separately in your usual workflow.
|
Then run the backend and frontend separately in your usual workflow.
|
||||||
|
|
||||||
|
- **Important**: Rootless docker should be used to avoid permission conflicts between the RTMP container and the Spring Boot host
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API Reference
|
## API Reference
|
||||||
|
|||||||
2874
frontend/package-lock.json
generated
2874
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-oauth/google": "^0.12.2",
|
"@react-oauth/google": "^0.12.2",
|
||||||
"@tailwindcss/vite": "^4.1.7",
|
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
@@ -24,11 +23,11 @@
|
|||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-range-slider-input": "^3.2.1",
|
"react-range-slider-input": "^3.2.1",
|
||||||
"react-router-dom": "^7.8.0",
|
"react-router-dom": "^7.8.0"
|
||||||
"tailwindcss": "^4.1.7"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.25.0",
|
"@eslint/js": "^9.25.0",
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@types/react": "^19.1.2",
|
"@types/react": "^19.1.2",
|
||||||
"@types/react-dom": "^19.1.2",
|
"@types/react-dom": "^19.1.2",
|
||||||
"@vitejs/plugin-react": "^4.4.1",
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
@@ -36,6 +35,7 @@
|
|||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.19",
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.30.1",
|
"typescript-eslint": "^8.30.1",
|
||||||
"vite": "^6.3.5",
|
"vite": "^6.3.5",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// App.jsx
|
// App.jsx
|
||||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||||
import MainLayout from './layouts/MainLayout';
|
import MainLayout from './layouts/MainLayout';
|
||||||
import ClipUpload from './pages/ClipUpload';
|
import ClipUpload from './pages/ClipUpload';
|
||||||
@@ -7,8 +7,9 @@ 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 MyVods from './pages/MyVods';
|
||||||
import VideoPlayer from "./pages/VideoPlayer.tsx";
|
import MediaPlayer from "./pages/MediaPlayer.tsx";
|
||||||
import VodPlayer from "./pages/VodPlayer.tsx";
|
import { getVodById, getVodMediaUrl } from "./utils/api/vods.ts";
|
||||||
|
import { getClipById, getClipMediaUrl } from "./utils/api/clips.ts";
|
||||||
import EditClip from "./pages/EditClip.tsx";
|
import EditClip from "./pages/EditClip.tsx";
|
||||||
import EditVod from "./pages/EditVod.tsx";
|
import EditVod from "./pages/EditVod.tsx";
|
||||||
import Profile from "./pages/Profile.tsx";
|
import Profile from "./pages/Profile.tsx";
|
||||||
@@ -31,8 +32,8 @@ function App() {
|
|||||||
<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="/my-vods" element={<MyVods />} />
|
||||||
<Route path="/video/:id" element={<VideoPlayer />} />
|
<Route path="/video/:id" element={<MediaPlayer noun="Clip" getMediaUrl={getClipMediaUrl} fetchDetails={getClipById} />} />
|
||||||
<Route path="/vod/:id" element={<VodPlayer />} />
|
<Route path="/vod/:id" element={<MediaPlayer noun="VoD" getMediaUrl={getVodMediaUrl} fetchDetails={getVodById} />} />
|
||||||
<Route path="/clips/:id/edit" element={<EditClip />} />
|
<Route path="/clips/:id/edit" element={<EditClip />} />
|
||||||
<Route path="/vods/:id/edit" element={<EditVod />} />
|
<Route path="/vods/:id/edit" element={<EditVod />} />
|
||||||
<Route path="/profile" element={<Profile />} />
|
<Route path="/profile" element={<Profile />} />
|
||||||
|
|||||||
@@ -1,13 +1,27 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { ArrowLeft, Lock, Maximize, Minimize, Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react";
|
import { ArrowLeft, Lock, Maximize, Minimize, Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react";
|
||||||
import type { Clip } from "../utils/types";
|
|
||||||
import { AuthError } from "../utils/api/client.ts";
|
import { AuthError } from "../utils/api/client.ts";
|
||||||
import { getClipById, getVideoBlob } from "../utils/api/clips.ts";
|
|
||||||
import Box from "../components/Box.tsx";
|
import Box from "../components/Box.tsx";
|
||||||
import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts";
|
import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts";
|
||||||
|
|
||||||
const VideoPlayer = () => {
|
// Both VoDs and clips share the same on-screen shape.
|
||||||
|
type MediaItem = {
|
||||||
|
title: string,
|
||||||
|
createdAt: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
type MediaPlayerProps = {
|
||||||
|
/** Human-readable name for the media, e.g. "VoD" or "clip". Used in messages. */
|
||||||
|
noun: string,
|
||||||
|
/** Builds the raw media stream URL for the given id. Loaded directly by the
|
||||||
|
* <video> element so the browser can issue HTTP Range requests. */
|
||||||
|
getMediaUrl: (id: string) => string,
|
||||||
|
/** Fetches the media metadata for the given id. */
|
||||||
|
fetchDetails: (id: string) => Promise<MediaItem | null>,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -17,10 +31,11 @@ const VideoPlayer = () => {
|
|||||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
const [videoUrl, setVideoUrl] = useState<string | undefined>(undefined);
|
const mediaUrl = id ? getMediaUrl(id) : undefined;
|
||||||
|
const [mediaLoaded, setMediaLoaded] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [notAuthenticated, setNotAuthenticated] = useState(false);
|
const [notAuthenticated, setNotAuthenticated] = useState(false);
|
||||||
const [clip, setClip] = useState<Clip | null>(null);
|
const [item, setItem] = useState<MediaItem | null>(null);
|
||||||
const [timeAgo, setTimeAgo] = useState("");
|
const [timeAgo, setTimeAgo] = useState("");
|
||||||
|
|
||||||
// Playback
|
// Playback
|
||||||
@@ -40,28 +55,28 @@ const VideoPlayer = () => {
|
|||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
// ── Data loading ────────────────────────────────────────────────────────
|
// ── Data loading ────────────────────────────────────────────────────────
|
||||||
|
// The video stream itself is loaded by the <video> element from `mediaUrl`,
|
||||||
|
// which lets the browser fetch it incrementally via Range requests. We only
|
||||||
|
// need to fetch the metadata here; it shares the media endpoint's auth, so a
|
||||||
|
// 401/403 here also stands in for the stream being inaccessible.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) { setError("Clip ID is required."); return; }
|
if (!id) { setError(`${noun} ID is required.`); return; }
|
||||||
|
|
||||||
getVideoBlob(id)
|
fetchDetails(id)
|
||||||
.then((blob) => setVideoUrl(URL.createObjectURL(blob)))
|
.then(setItem)
|
||||||
.catch((e) => { if (e instanceof AuthError) setNotAuthenticated(true); });
|
|
||||||
|
|
||||||
getClipById(id)
|
|
||||||
.then(setClip)
|
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (e instanceof AuthError) setNotAuthenticated(true);
|
if (e instanceof AuthError) setNotAuthenticated(true);
|
||||||
else setError("Failed to load clip details.");
|
else setError(`Failed to load ${noun.toLowerCase()} details.`);
|
||||||
});
|
});
|
||||||
}, [id]);
|
}, [id, noun, fetchDetails]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!clip?.createdAt) return;
|
if (!item?.createdAt) return;
|
||||||
const update = () => setTimeAgo(dateToTimeAgo(stringToDate(clip.createdAt)));
|
const update = () => setTimeAgo(dateToTimeAgo(stringToDate(item.createdAt)));
|
||||||
update();
|
update();
|
||||||
const interval = setInterval(update, 1000);
|
const interval = setInterval(update, 1000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [clip]);
|
}, [item]);
|
||||||
|
|
||||||
// ── Controls visibility ─────────────────────────────────────────────────
|
// ── Controls visibility ─────────────────────────────────────────────────
|
||||||
const scheduleHide = useCallback(() => {
|
const scheduleHide = useCallback(() => {
|
||||||
@@ -192,7 +207,7 @@ const VideoPlayer = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-lg font-semibold text-gray-900">Not authenticated</p>
|
<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 clip.</p>
|
<p className="text-sm text-gray-500 mt-1">You don't have access to this {noun.toLowerCase()}.</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
@@ -227,7 +242,10 @@ const VideoPlayer = () => {
|
|||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
className="w-full h-full object-contain"
|
className="w-full h-full object-contain"
|
||||||
src={videoUrl}
|
src={mediaUrl}
|
||||||
|
// Send auth cookies with the cross-origin Range requests.
|
||||||
|
crossOrigin="use-credentials"
|
||||||
|
preload="metadata"
|
||||||
onPlay={() => { setIsPlaying(true); scheduleHide(); }}
|
onPlay={() => { setIsPlaying(true); scheduleHide(); }}
|
||||||
onPause={() => {
|
onPause={() => {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
@@ -235,14 +253,17 @@ const VideoPlayer = () => {
|
|||||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
||||||
}}
|
}}
|
||||||
onTimeUpdate={handleTimeUpdate}
|
onTimeUpdate={handleTimeUpdate}
|
||||||
onLoadedMetadata={() => { if (videoRef.current) setDuration(videoRef.current.duration); }}
|
onLoadedMetadata={() => {
|
||||||
|
if (videoRef.current) setDuration(videoRef.current.duration);
|
||||||
|
setMediaLoaded(true);
|
||||||
|
}}
|
||||||
onWaiting={() => setIsWaiting(true)}
|
onWaiting={() => setIsWaiting(true)}
|
||||||
onCanPlay={() => setIsWaiting(false)}
|
onCanPlay={() => setIsWaiting(false)}
|
||||||
onError={(e) => setError(e.currentTarget.error?.message || "An error occurred.")}
|
onError={(e) => setError(e.currentTarget.error?.message || "An error occurred.")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Buffering spinner */}
|
{/* Buffering spinner */}
|
||||||
{isWaiting && videoUrl && (
|
{isWaiting && mediaUrl && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
<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 className="w-11 h-11 rounded-full border-[3px] border-white/20 border-t-white animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
@@ -311,7 +332,7 @@ const VideoPlayer = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="text-red-500 text-sm mt-3">{error}</div>}
|
{error && <div className="text-red-500 text-sm mt-3">{error}</div>}
|
||||||
{!videoUrl && !error && (
|
{!mediaLoaded && !error && (
|
||||||
<div className="text-gray-400 text-sm mt-3 flex items-center gap-2">
|
<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" />
|
<div className="w-3 h-3 rounded-full border-2 border-gray-300 border-t-gray-500 animate-spin" />
|
||||||
Loading video...
|
Loading video...
|
||||||
@@ -319,11 +340,11 @@ const VideoPlayer = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Box className="p-4 flex-shrink-0">
|
<Box className="p-4 flex-shrink-0">
|
||||||
<p className="text-xl font-semibold text-gray-900">{clip?.title || "(No Title)"}</p>
|
<p className="text-xl font-semibold text-gray-900">{item?.title || "(No Title)"}</p>
|
||||||
<p className="text-sm text-gray-500 mt-1">{timeAgo}</p>
|
<p className="text-sm text-gray-500 mt-1">{timeAgo}</p>
|
||||||
</Box>
|
</Box>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default VideoPlayer;
|
export default MediaPlayer;
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
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 } from "../utils/api/client.ts";
|
|
||||||
import { getVodById, getVodBlob } from "../utils/api/vods.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;
|
|
||||||
@@ -25,16 +25,10 @@ const getClipById = async (id: string): Promise<Clip | null> => {
|
|||||||
return result.data;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getVideoBlob = async (id: string): Promise<Blob> => {
|
// URL for the raw media stream. The <video> element loads this directly so the
|
||||||
const response = await fetch(API_URL + `/api/v1/clips/${id}/media`, { credentials: 'include' });
|
// browser can issue HTTP Range requests (progressive loading + seeking) instead
|
||||||
|
// of downloading the whole file up front.
|
||||||
if (!response.ok) {
|
const getClipMediaUrl = (id: string): string => API_URL + `/api/v1/clips/${id}/media`;
|
||||||
if (response.status === 401 || response.status === 403) throw new AuthError();
|
|
||||||
throw new Error(`Failed to fetch video ${id}: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.blob();
|
|
||||||
};
|
|
||||||
|
|
||||||
const patchClip = async (id: number, data: { title?: string; description?: string }): Promise<Clip> => {
|
const patchClip = async (id: number, data: { title?: string; description?: string }): Promise<Clip> => {
|
||||||
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
|
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
|
||||||
@@ -64,4 +58,4 @@ 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}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export { getClips, getClipById, getVideoBlob, patchClip, deleteClip };
|
export { getClips, getClipById, getClipMediaUrl, patchClip, deleteClip };
|
||||||
|
|||||||
@@ -25,16 +25,10 @@ const getVodById = async (id: string): Promise<Vod | null> => {
|
|||||||
return result.data;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getVodBlob = async (id: string): Promise<Blob> => {
|
// URL for the raw media stream. The <video> element loads this directly so the
|
||||||
const response = await fetch(API_URL + `/api/v1/vods/${id}/media`, { credentials: 'include' });
|
// browser can issue HTTP Range requests (progressive loading + seeking) instead
|
||||||
|
// of downloading the whole file up front.
|
||||||
if (!response.ok) {
|
const getVodMediaUrl = (id: string): string => API_URL + `/api/v1/vods/${id}/media`;
|
||||||
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 patchVod = async (id: number, data: { title?: string; description?: string }): Promise<Vod> => {
|
||||||
const response = await fetch(API_URL + `/api/v1/vods/${id}`, {
|
const response = await fetch(API_URL + `/api/v1/vods/${id}`, {
|
||||||
@@ -64,4 +58,4 @@ const deleteVod = async (id: number): Promise<void> => {
|
|||||||
if (result.status === 'error') throw new Error(`Failed to delete vod: ${result.message}`);
|
if (result.status === 'error') throw new Error(`Failed to delete vod: ${result.message}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export { getVods, getVodById, getVodBlob, patchVod, deleteVod };
|
export { getVods, getVodById, getVodMediaUrl, patchVod, deleteVod };
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import org.springframework.http.MediaTypeFactory;
|
|||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -77,8 +78,8 @@ public class ClipController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}/media")
|
@GetMapping(value = "/{id}/media", produces = MediaType.ALL_VALUE)
|
||||||
public ResponseEntity<Resource> downloadClip(@PathVariable Long id) {
|
public ResponseEntity<Resource> downloadClip(@PathVariable Long id) throws IOException {
|
||||||
Resource resource = clipService.downloadClip(id);
|
Resource resource = clipService.downloadClip(id);
|
||||||
|
|
||||||
if (resource == null || !resource.exists()) {
|
if (resource == null || !resource.exists()) {
|
||||||
@@ -86,8 +87,11 @@ public class ClipController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok()
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
|
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
|
String.format(FILENAME_HEADER, resource.getFilename()))
|
||||||
|
.contentType(MediaTypeFactory.getMediaType(resource)
|
||||||
|
.orElse(MediaType.APPLICATION_OCTET_STREAM))
|
||||||
|
.contentLength(resource.contentLength())
|
||||||
.body(resource);
|
.body(resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,13 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(ClipMediaUnavailable.class)
|
||||||
|
public ResponseEntity<APIResponse<Void>> handleClipMediaUnavailable(ClipMediaUnavailable ex) {
|
||||||
|
logger.error("ClipMediaUnavailable (clip {}): {}", ex.getClipId(), ex.getMessage());
|
||||||
|
APIResponse<Void> response = new APIResponse<>(ERROR, "Clip media is currently unavailable", null);
|
||||||
|
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(response);
|
||||||
|
}
|
||||||
|
|
||||||
@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());
|
||||||
@@ -109,6 +116,13 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(VodMediaUnavailable.class)
|
||||||
|
public ResponseEntity<APIResponse<Void>> handleVodMediaUnavailable(VodMediaUnavailable ex) {
|
||||||
|
logger.error("VodMediaUnavailable (vod {}): {}", ex.getVodId(), ex.getMessage());
|
||||||
|
APIResponse<Void> response = new APIResponse<>(ERROR, "Vod media is currently unavailable", null);
|
||||||
|
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(response);
|
||||||
|
}
|
||||||
|
|
||||||
@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());
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ public class MediaController {
|
|||||||
public ResponseEntity<APIResponse<UUIDResponse>> compress(
|
public ResponseEntity<APIResponse<UUIDResponse>> compress(
|
||||||
@RequestParam("file")MultipartFile file,
|
@RequestParam("file")MultipartFile file,
|
||||||
@Valid @ModelAttribute ClipOptions options
|
@Valid @ModelAttribute ClipOptions options
|
||||||
) throws IOException, InterruptedException {
|
) throws IOException {
|
||||||
Job job = mediaService.compress(file, options);
|
Job job = mediaService.compress(file, options);
|
||||||
return ResponseEntity.ok(new APIResponse<>(
|
return ResponseEntity.ok(new APIResponse<>(
|
||||||
SUCCESS,
|
SUCCESS,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import org.springframework.http.MediaTypeFactory;
|
|||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -54,8 +55,8 @@ public class VodController {
|
|||||||
"Vod with ID " + id + " has been deleted"));
|
"Vod with ID " + id + " has been deleted"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}/media")
|
@GetMapping(value = "/{id}/media", produces = MediaType.ALL_VALUE)
|
||||||
public ResponseEntity<Resource> downloadVod(@PathVariable Long id) {
|
public ResponseEntity<Resource> downloadVod(@PathVariable Long id) throws IOException {
|
||||||
Resource resource = vodService.downloadVod(id);
|
Resource resource = vodService.downloadVod(id);
|
||||||
|
|
||||||
if (resource == null || !resource.exists()) {
|
if (resource == null || !resource.exists()) {
|
||||||
@@ -63,8 +64,11 @@ public class VodController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok()
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
|
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
|
String.format(FILENAME_HEADER, resource.getFilename()))
|
||||||
|
.contentType(MediaTypeFactory.getMediaType(resource)
|
||||||
|
.orElse(MediaType.APPLICATION_OCTET_STREAM))
|
||||||
|
.contentLength(resource.contentLength())
|
||||||
.body(resource);
|
.body(resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.ddf.vodsystem.exceptions;
|
||||||
|
|
||||||
|
public class ClipMediaUnavailable extends RuntimeException {
|
||||||
|
private final Long clipId;
|
||||||
|
|
||||||
|
public ClipMediaUnavailable(Long clipId, String message) {
|
||||||
|
this(clipId, message, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ClipMediaUnavailable(Long clipId, String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
this.clipId = clipId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getClipId() {
|
||||||
|
return clipId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.ddf.vodsystem.exceptions;
|
||||||
|
|
||||||
|
public class VodMediaUnavailable extends RuntimeException {
|
||||||
|
private final Long vodId;
|
||||||
|
|
||||||
|
public VodMediaUnavailable(Long vodId, String message) {
|
||||||
|
this(vodId, message, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public VodMediaUnavailable(Long vodId, String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
this.vodId = vodId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getVodId() {
|
||||||
|
return vodId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -156,6 +156,10 @@ public class ClipService {
|
|||||||
throw new ClipNotFound("Clip file not found");
|
throw new ClipNotFound("Clip file not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!Files.isReadable(file)) {
|
||||||
|
throw new ClipMediaUnavailable(id, "Clip file is not readable");
|
||||||
|
}
|
||||||
|
|
||||||
return new FileSystemResource(file);
|
return new FileSystemResource(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import java.io.File;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
import java.nio.file.StandardCopyOption;
|
import java.nio.file.StandardCopyOption;
|
||||||
|
|
||||||
@@ -129,11 +130,29 @@ public class DirectoryService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void checkDirPermissions() {
|
||||||
|
List<Path> dirs = List.of(
|
||||||
|
Path.of(tempInputsDir),
|
||||||
|
Path.of(tempOutputsDir),
|
||||||
|
Path.of(usersDir)
|
||||||
|
);
|
||||||
|
|
||||||
|
List<Path> bad = dirs.stream()
|
||||||
|
.filter(p -> !Files.isDirectory(p) || !Files.isReadable(p) || !Files.isWritable(p))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (!bad.isEmpty()) {
|
||||||
|
throw new IllegalStateException("Unusable directories: " + bad);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void createDirectoriesIfNotExist() throws IOException {
|
public void createDirectoriesIfNotExist() throws IOException {
|
||||||
Files.createDirectories(Path.of(tempInputsDir));
|
Files.createDirectories(Path.of(tempInputsDir));
|
||||||
Files.createDirectories(Path.of(tempOutputsDir));
|
Files.createDirectories(Path.of(tempOutputsDir));
|
||||||
Files.createDirectories(Path.of(usersDir));
|
Files.createDirectories(Path.of(usersDir));
|
||||||
|
|
||||||
|
checkDirPermissions();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Scheduled(fixedRate = TEMP_DIR_CLEANUP_RATE)
|
@Scheduled(fixedRate = TEMP_DIR_CLEANUP_RATE)
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ public class MediaService {
|
|||||||
);
|
);
|
||||||
}).exceptionally(ex -> {
|
}).exceptionally(ex -> {
|
||||||
job.setState(JobState.FAILED);
|
job.setState(JobState.FAILED);
|
||||||
job.setErrorOutput(ex.getMessage());
|
job.setErrorOutput("Compression job failed");
|
||||||
logger.error("Compression job with UUID {} failed due to: {}", job.getUuid(), ex.getMessage());
|
logger.error("Compression job with UUID {} failed due to: {}", job.getUuid(), ex.getMessage());
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -139,6 +139,10 @@ public class VodService {
|
|||||||
throw new VodNotFound("Vod file not found for ID: " + id);
|
throw new VodNotFound("Vod file not found for ID: " + id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!Files.isReadable(file)) {
|
||||||
|
throw new VodMediaUnavailable(id, "Vod file is not readable");
|
||||||
|
}
|
||||||
|
|
||||||
return new FileSystemResource(file);
|
return new FileSystemResource(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user