Compare commits
2 Commits
88cf2103f6
...
09cc841dfd
| Author | SHA1 | Date | |
|---|---|---|---|
| 09cc841dfd | |||
| b0055277f3 |
@@ -8,8 +8,8 @@ import {useEffect} from "react";
|
||||
import MyClips from './pages/MyClips';
|
||||
import MyVods from './pages/MyVods';
|
||||
import MediaPlayer from "./pages/MediaPlayer.tsx";
|
||||
import { getVodById, getVodBlob } from "./utils/api/vods.ts";
|
||||
import { getClipById, getVideoBlob } from "./utils/api/clips.ts";
|
||||
import { getVodById, getVodMediaUrl } from "./utils/api/vods.ts";
|
||||
import { getClipById, getClipMediaUrl } from "./utils/api/clips.ts";
|
||||
import EditClip from "./pages/EditClip.tsx";
|
||||
import EditVod from "./pages/EditVod.tsx";
|
||||
import Profile from "./pages/Profile.tsx";
|
||||
@@ -32,8 +32,8 @@ function App() {
|
||||
<Route path="/create/:id" element={<ClipEdit />} />
|
||||
<Route path="/my-clips" element={<MyClips />} />
|
||||
<Route path="/my-vods" element={<MyVods />} />
|
||||
<Route path="/video/:id" element={<MediaPlayer noun="Clip" fetchBlob={getVideoBlob} fetchDetails={getClipById} />} />
|
||||
<Route path="/vod/:id" element={<MediaPlayer noun="VoD" fetchBlob={getVodBlob} fetchDetails={getVodById} />} />
|
||||
<Route path="/video/:id" element={<MediaPlayer noun="Clip" getMediaUrl={getClipMediaUrl} fetchDetails={getClipById} />} />
|
||||
<Route path="/vod/:id" element={<MediaPlayer noun="VoD" getMediaUrl={getVodMediaUrl} fetchDetails={getVodById} />} />
|
||||
<Route path="/clips/:id/edit" element={<EditClip />} />
|
||||
<Route path="/vods/:id/edit" element={<EditVod />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
|
||||
@@ -14,13 +14,14 @@ type MediaItem = {
|
||||
type MediaPlayerProps = {
|
||||
/** Human-readable name for the media, e.g. "VoD" or "clip". Used in messages. */
|
||||
noun: string,
|
||||
/** Fetches the raw video blob for the given id. */
|
||||
fetchBlob: (id: string) => Promise<Blob>,
|
||||
/** 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, fetchBlob, fetchDetails }: MediaPlayerProps) => {
|
||||
const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -30,7 +31,8 @@ const MediaPlayer = ({ noun, fetchBlob, fetchDetails }: MediaPlayerProps) => {
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// 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 [notAuthenticated, setNotAuthenticated] = useState(false);
|
||||
const [item, setItem] = useState<MediaItem | null>(null);
|
||||
@@ -53,20 +55,20 @@ const MediaPlayer = ({ noun, fetchBlob, fetchDetails }: MediaPlayerProps) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// ── 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(() => {
|
||||
if (!id) { setError(`${noun} ID is required.`); return; }
|
||||
|
||||
fetchBlob(id)
|
||||
.then((blob) => setVideoUrl(URL.createObjectURL(blob)))
|
||||
.catch((e) => { if (e instanceof AuthError) setNotAuthenticated(true); });
|
||||
|
||||
fetchDetails(id)
|
||||
.then(setItem)
|
||||
.catch((e) => {
|
||||
if (e instanceof AuthError) setNotAuthenticated(true);
|
||||
else setError(`Failed to load ${noun.toLowerCase()} details.`);
|
||||
});
|
||||
}, [id, noun, fetchBlob, fetchDetails]);
|
||||
}, [id, noun, fetchDetails]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!item?.createdAt) return;
|
||||
@@ -240,7 +242,10 @@ const MediaPlayer = ({ noun, fetchBlob, fetchDetails }: MediaPlayerProps) => {
|
||||
<video
|
||||
ref={videoRef}
|
||||
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(); }}
|
||||
onPause={() => {
|
||||
setIsPlaying(false);
|
||||
@@ -248,14 +253,17 @@ const MediaPlayer = ({ noun, fetchBlob, fetchDetails }: MediaPlayerProps) => {
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
||||
}}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={() => { if (videoRef.current) setDuration(videoRef.current.duration); }}
|
||||
onLoadedMetadata={() => {
|
||||
if (videoRef.current) setDuration(videoRef.current.duration);
|
||||
setMediaLoaded(true);
|
||||
}}
|
||||
onWaiting={() => setIsWaiting(true)}
|
||||
onCanPlay={() => setIsWaiting(false)}
|
||||
onError={(e) => setError(e.currentTarget.error?.message || "An error occurred.")}
|
||||
/>
|
||||
|
||||
{/* Buffering spinner */}
|
||||
{isWaiting && videoUrl && (
|
||||
{isWaiting && mediaUrl && (
|
||||
<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>
|
||||
@@ -324,7 +332,7 @@ const MediaPlayer = ({ noun, fetchBlob, fetchDetails }: MediaPlayerProps) => {
|
||||
</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="w-3 h-3 rounded-full border-2 border-gray-300 border-t-gray-500 animate-spin" />
|
||||
Loading video...
|
||||
|
||||
@@ -25,16 +25,10 @@ const getClipById = async (id: string): Promise<Clip | null> => {
|
||||
return result.data;
|
||||
};
|
||||
|
||||
const getVideoBlob = async (id: string): Promise<Blob> => {
|
||||
const response = await fetch(API_URL + `/api/v1/clips/${id}/media`, { credentials: 'include' });
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) throw new AuthError();
|
||||
throw new Error(`Failed to fetch video ${id}: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
};
|
||||
// URL for the raw media stream. The <video> element loads this directly so the
|
||||
// browser can issue HTTP Range requests (progressive loading + seeking) instead
|
||||
// of downloading the whole file up front.
|
||||
const getClipMediaUrl = (id: string): string => API_URL + `/api/v1/clips/${id}/media`;
|
||||
|
||||
const patchClip = async (id: number, data: { title?: string; description?: string }): Promise<Clip> => {
|
||||
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}`);
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
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();
|
||||
};
|
||||
// URL for the raw media stream. The <video> element loads this directly so the
|
||||
// browser can issue HTTP Range requests (progressive loading + seeking) instead
|
||||
// of downloading the whole file up front.
|
||||
const getVodMediaUrl = (id: string): string => API_URL + `/api/v1/vods/${id}/media`;
|
||||
|
||||
const patchVod = async (id: number, data: { title?: string; description?: string }): Promise<Vod> => {
|
||||
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}`);
|
||||
};
|
||||
|
||||
export { getVods, getVodById, getVodBlob, patchVod, deleteVod };
|
||||
export { getVods, getVodById, getVodMediaUrl, patchVod, deleteVod };
|
||||
|
||||
@@ -6,10 +6,7 @@ import com.ddf.vodsystem.controllers.dto.ClipUpdateRequest;
|
||||
import com.ddf.vodsystem.entities.Clip;
|
||||
import com.ddf.vodsystem.services.ClipService;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.support.ResourceRegion;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpRange;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.MediaTypeFactory;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -81,40 +78,23 @@ public class ClipController {
|
||||
);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/media")
|
||||
public ResponseEntity<?> downloadClip(@PathVariable Long id, @RequestHeader HttpHeaders headers) throws IOException {
|
||||
@GetMapping(value = "/{id}/media", produces = MediaType.ALL_VALUE)
|
||||
public ResponseEntity<Resource> downloadClip(@PathVariable Long id) throws IOException {
|
||||
Resource resource = clipService.downloadClip(id);
|
||||
|
||||
if (resource == null || !resource.exists()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
List<HttpRange> ranges = headers.getRange();
|
||||
|
||||
if (!ranges.isEmpty()) {
|
||||
ResourceRegion region = createResourceRegion(resource, ranges.getFirst());
|
||||
|
||||
return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
|
||||
.header(HttpHeaders.ACCEPT_RANGES, "bytes")
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
|
||||
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
|
||||
.body(region);
|
||||
}
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.ACCEPT_RANGES, "bytes")
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
|
||||
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
String.format(FILENAME_HEADER, resource.getFilename()))
|
||||
.contentType(MediaTypeFactory.getMediaType(resource)
|
||||
.orElse(MediaType.APPLICATION_OCTET_STREAM))
|
||||
.contentLength(resource.contentLength())
|
||||
.body(resource);
|
||||
}
|
||||
|
||||
private ResourceRegion createResourceRegion(Resource resource, HttpRange range) throws IOException {
|
||||
long contentLength = resource.contentLength();
|
||||
long start = range.getRangeStart(contentLength);
|
||||
long end = range.getRangeEnd(contentLength);
|
||||
return new ResourceRegion(resource, start, end - start + 1);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/thumbnail")
|
||||
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) {
|
||||
Resource resource = clipService.downloadThumbnail(id);
|
||||
|
||||
@@ -102,6 +102,13 @@ public class GlobalExceptionHandler {
|
||||
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)
|
||||
public ResponseEntity<APIResponse<Void>> handleVodNotFound(VodNotFound ex) {
|
||||
logger.error("VodNotFound: {}", ex.getMessage());
|
||||
@@ -109,6 +116,13 @@ public class GlobalExceptionHandler {
|
||||
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)
|
||||
public ResponseEntity<APIResponse<Void>> handleNotStreaming(NotStreaming ex) {
|
||||
logger.error("NotStreaming: {}", ex.getMessage());
|
||||
|
||||
@@ -6,10 +6,7 @@ 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.core.io.support.ResourceRegion;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpRange;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.MediaTypeFactory;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -58,40 +55,23 @@ public class VodController {
|
||||
"Vod with ID " + id + " has been deleted"));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/media")
|
||||
public ResponseEntity<?> downloadVod(@PathVariable Long id, @RequestHeader HttpHeaders headers) throws IOException {
|
||||
@GetMapping(value = "/{id}/media", produces = MediaType.ALL_VALUE)
|
||||
public ResponseEntity<Resource> downloadVod(@PathVariable Long id) throws IOException {
|
||||
Resource resource = vodService.downloadVod(id);
|
||||
|
||||
if (resource == null || !resource.exists()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
List<HttpRange> ranges = headers.getRange();
|
||||
|
||||
if (!ranges.isEmpty()) {
|
||||
ResourceRegion region = createResourceRegion(resource, ranges.getFirst());
|
||||
|
||||
return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
|
||||
.header(HttpHeaders.ACCEPT_RANGES, "bytes")
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
|
||||
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
|
||||
.body(region);
|
||||
}
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.ACCEPT_RANGES, "bytes")
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
|
||||
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
String.format(FILENAME_HEADER, resource.getFilename()))
|
||||
.contentType(MediaTypeFactory.getMediaType(resource)
|
||||
.orElse(MediaType.APPLICATION_OCTET_STREAM))
|
||||
.contentLength(resource.contentLength())
|
||||
.body(resource);
|
||||
}
|
||||
|
||||
private ResourceRegion createResourceRegion(Resource resource, HttpRange range) throws IOException {
|
||||
long contentLength = resource.contentLength();
|
||||
long start = range.getRangeStart(contentLength);
|
||||
long end = range.getRangeEnd(contentLength);
|
||||
return new ResourceRegion(resource, start, end - start + 1);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/thumbnail")
|
||||
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) {
|
||||
Resource resource = vodService.downloadThumbnail(id);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
if (!Files.isReadable(file)) {
|
||||
throw new ClipMediaUnavailable(id, "Clip file is not readable");
|
||||
}
|
||||
|
||||
return new FileSystemResource(file);
|
||||
}
|
||||
|
||||
|
||||
@@ -139,6 +139,10 @@ public class VodService {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user