Stream video through backend with Range request support
Previously videos were served via direct RustFS URLs, which meant: - No HTTP Range support (browsers had to download the entire file) - Large videos couldn't play at all - Video player rendered broken Now videos stream through GET /api/videos/:id/stream which: - Proxies video data from RustFS to the browser - Supports Range requests (HTTP 206 Partial Content) for seeking - Sets proper headers (Accept-Ranges, Content-Range, Content-Type) - Caches for 24 hours Frontend changes: - VideoPlayer: added playsInline, preload=metadata, object-contain, error state - VideoDetail: removed duplicate inline video, now uses VideoPlayer component - index.css: style WebKit video controls (dark panel, no border-radius)
This commit is contained in:
@@ -13,10 +13,12 @@ import {
|
||||
uploadObject,
|
||||
deleteObject,
|
||||
getObjectUrl,
|
||||
getStreamUrl,
|
||||
createMultipartUpload,
|
||||
getPresignedUploadPartUrl,
|
||||
completeMultipartUpload,
|
||||
abortMultipartUpload,
|
||||
getVideoStream,
|
||||
} from "../services/rustfs";
|
||||
import { parseMtvFile } from "../services/mtv-parser";
|
||||
import { getMapInfo } from "../services/momentum-api";
|
||||
@@ -212,7 +214,7 @@ export const videoRoutes = new Elysia({ prefix: "/api/videos" })
|
||||
parts,
|
||||
uploadId,
|
||||
},
|
||||
videoUrl: getObjectUrl(videoKey),
|
||||
videoUrl: getStreamUrl(id),
|
||||
mtvUrl: getObjectUrl(mtvKey),
|
||||
thumbnailUrl: thumbnailKey
|
||||
? getObjectUrl(thumbnailKey)
|
||||
@@ -304,7 +306,7 @@ export const videoRoutes = new Elysia({ prefix: "/api/videos" })
|
||||
parts,
|
||||
uploadId,
|
||||
},
|
||||
videoUrl: getObjectUrl(videoKey),
|
||||
videoUrl: getStreamUrl(id),
|
||||
mtvUrl: getObjectUrl(mtvKey),
|
||||
thumbnailUrl: thumbnailKey ? getObjectUrl(thumbnailKey) : undefined,
|
||||
};
|
||||
@@ -333,7 +335,7 @@ export const videoRoutes = new Elysia({ prefix: "/api/videos" })
|
||||
|
||||
return {
|
||||
...video,
|
||||
videoUrl: getObjectUrl(video.videoKey),
|
||||
videoUrl: getStreamUrl(video.id),
|
||||
mtvUrl: getObjectUrl(video.mtvKey),
|
||||
thumbnailUrl: video.thumbnailKey
|
||||
? getObjectUrl(video.thumbnailKey)
|
||||
@@ -341,12 +343,52 @@ export const videoRoutes = new Elysia({ prefix: "/api/videos" })
|
||||
};
|
||||
})
|
||||
|
||||
.get("/:id/stream", async ({ params: { id }, request, set }) => {
|
||||
const video = getVideoById(id);
|
||||
if (!video) return status(404, { error: "Not found" });
|
||||
|
||||
const rangeHeader = request.headers.get("Range");
|
||||
|
||||
try {
|
||||
const result = await getVideoStream(video.videoKey, rangeHeader);
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set("Content-Type", result.contentType);
|
||||
headers.set("Accept-Ranges", "bytes");
|
||||
headers.set("Cache-Control", "public, max-age=86400");
|
||||
|
||||
if (result.range) {
|
||||
headers.set(
|
||||
"Content-Range",
|
||||
`bytes ${result.range.start}-${result.range.end}/${result.contentLength}`,
|
||||
);
|
||||
headers.set(
|
||||
"Content-Length",
|
||||
String(result.range.end - result.range.start + 1),
|
||||
);
|
||||
return new Response(result.stream, {
|
||||
status: 206,
|
||||
headers,
|
||||
});
|
||||
} else {
|
||||
headers.set("Content-Length", String(result.contentLength));
|
||||
return new Response(result.stream, {
|
||||
status: 200,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Stream error:", err);
|
||||
return status(404, { error: "Video not found" });
|
||||
}
|
||||
})
|
||||
|
||||
.get("/:id", ({ params: { id } }) => {
|
||||
const video = getVideoById(id);
|
||||
if (!video) return status(404, { error: "Not found" });
|
||||
return {
|
||||
...video,
|
||||
videoUrl: getObjectUrl(video.videoKey),
|
||||
videoUrl: getStreamUrl(video.id),
|
||||
mtvUrl: getObjectUrl(video.mtvKey),
|
||||
thumbnailUrl: video.thumbnailKey
|
||||
? getObjectUrl(video.thumbnailKey)
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
UploadPartCommand,
|
||||
CompleteMultipartUploadCommand,
|
||||
AbortMultipartUploadCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
|
||||
@@ -150,3 +152,81 @@ export async function abortMultipartUpload(
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export interface VideoStreamResult {
|
||||
stream: ReadableStream<Uint8Array>;
|
||||
contentType: string;
|
||||
contentLength: number;
|
||||
range?: {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
}
|
||||
|
||||
export async function getVideoStream(
|
||||
key: string,
|
||||
rangeHeader?: string | null,
|
||||
): Promise<VideoStreamResult> {
|
||||
const s3 = getRustFsClient();
|
||||
|
||||
// Get object metadata first to know total size
|
||||
const headResult = await s3.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: RUSTFS_BUCKET,
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
const totalSize = headResult.ContentLength ?? 0;
|
||||
const contentType = headResult.ContentType || "video/webm";
|
||||
|
||||
let range: { start: number; end: number } | undefined;
|
||||
let command: GetObjectCommand;
|
||||
|
||||
if (rangeHeader) {
|
||||
// Parse Range header like "bytes=0-1023" or "bytes=1024-"
|
||||
const match = rangeHeader.match(/bytes=(\d*)-(\d*)/);
|
||||
if (match) {
|
||||
const start = match[1] ? parseInt(match[1], 10) : 0;
|
||||
const end = match[2] ? parseInt(match[2], 10) : totalSize - 1;
|
||||
range = { start, end: Math.min(end, totalSize - 1) };
|
||||
|
||||
command = new GetObjectCommand({
|
||||
Bucket: RUSTFS_BUCKET,
|
||||
Key: key,
|
||||
Range: `bytes=${range.start}-${range.end}`,
|
||||
});
|
||||
} else {
|
||||
command = new GetObjectCommand({
|
||||
Bucket: RUSTFS_BUCKET,
|
||||
Key: key,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
command = new GetObjectCommand({
|
||||
Bucket: RUSTFS_BUCKET,
|
||||
Key: key,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await s3.send(command);
|
||||
if (!result.Body) {
|
||||
throw new Error("Empty response body from RustFS");
|
||||
}
|
||||
|
||||
// Convert SDK stream to web ReadableStream
|
||||
const sdkStream = result.Body as any;
|
||||
const webStream: ReadableStream<Uint8Array> = sdkStream.transformToWebStream
|
||||
? sdkStream.transformToWebStream()
|
||||
: sdkStream;
|
||||
|
||||
return {
|
||||
stream: webStream,
|
||||
contentType,
|
||||
contentLength: totalSize,
|
||||
range,
|
||||
};
|
||||
}
|
||||
|
||||
export function getStreamUrl(videoId: string): string {
|
||||
return `/api/videos/${videoId}/stream`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user