rspace-online/modules/tube/mod.ts

214 lines
6.8 KiB
TypeScript

/**
* Tube module — community video hosting & live streaming.
*
* Video library (Cloudflare R2 bucket), HLS live streaming,
* and RTMP ingest support. No database — metadata from S3 listing.
*/
import { Hono } from "hono";
import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
import { S3Client, ListObjectsV2Command, GetObjectCommand, HeadObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
const routes = new Hono();
// ── R2 / S3 config ──
const R2_ENDPOINT = process.env.R2_ENDPOINT || "";
const R2_BUCKET = process.env.R2_BUCKET || "rtube-videos";
const R2_ACCESS_KEY_ID = process.env.R2_ACCESS_KEY_ID || "";
const R2_SECRET_ACCESS_KEY = process.env.R2_SECRET_ACCESS_KEY || "";
const R2_PUBLIC_URL = process.env.R2_PUBLIC_URL || "";
const VIDEO_EXTENSIONS = new Set([
".mp4", ".mkv", ".webm", ".mov", ".avi", ".wmv", ".flv", ".m4v",
]);
// ── S3 Client (lazy init) ──
let s3: S3Client | null = null;
function getS3(): S3Client | null {
if (!R2_ENDPOINT) return null;
if (!s3) {
s3 = new S3Client({
region: "auto",
endpoint: R2_ENDPOINT,
credentials: {
accessKeyId: R2_ACCESS_KEY_ID,
secretAccessKey: R2_SECRET_ACCESS_KEY,
},
});
}
return s3;
}
// ── S3-compatible listing ──
async function listVideos(): Promise<Array<{ name: string; size: number; lastModified?: string }>> {
const client = getS3();
if (!client) {
// Return demo data when R2 not configured
return [
{ name: "welcome-to-rtube.mp4", size: 12_500_000 },
{ name: "community-meeting-2026.mp4", size: 85_000_000 },
{ name: "workshop-recording.webm", size: 45_000_000 },
];
}
try {
const command = new ListObjectsV2Command({ Bucket: R2_BUCKET, MaxKeys: 200 });
const response = await client.send(command);
const items: Array<{ name: string; size: number; lastModified?: string }> = [];
for (const obj of response.Contents || []) {
if (!obj.Key) continue;
const ext = obj.Key.substring(obj.Key.lastIndexOf(".")).toLowerCase();
if (VIDEO_EXTENSIONS.has(ext)) {
items.push({
name: obj.Key,
size: obj.Size || 0,
lastModified: obj.LastModified?.toISOString(),
});
}
}
return items.sort((a, b) => a.name.localeCompare(b.name));
} catch (e) {
console.error("[Tube] S3 list error:", e);
return [];
}
}
// ── API routes ──
// GET /api/videos — list videos from bucket
routes.get("/api/videos", async (c) => {
const videos = await listVideos();
return c.json({ videos, r2Configured: !!R2_ENDPOINT });
});
// GET /api/v/:path — video streaming with HTTP range request support
routes.get("/api/v/*", async (c) => {
const client = getS3();
if (!client) return c.json({ error: "R2 not configured" }, 503);
const path = c.req.path.replace(/^.*\/api\/v\//, "");
if (!path) return c.json({ error: "Path required" }, 400);
try {
// Get object metadata for Content-Length
const head = await client.send(new HeadObjectCommand({ Bucket: R2_BUCKET, Key: path }));
const totalSize = head.ContentLength || 0;
const contentType = head.ContentType || "video/mp4";
const rangeHeader = c.req.header("range");
if (rangeHeader) {
// Parse range header
const match = rangeHeader.match(/bytes=(\d+)-(\d*)/);
if (!match) return c.json({ error: "Invalid range" }, 400);
const start = parseInt(match[1]);
const end = match[2] ? parseInt(match[2]) : Math.min(start + 5 * 1024 * 1024 - 1, totalSize - 1);
const chunkSize = end - start + 1;
const obj = await client.send(new GetObjectCommand({
Bucket: R2_BUCKET,
Key: path,
Range: `bytes=${start}-${end}`,
}));
return new Response(obj.Body as ReadableStream, {
status: 206,
headers: {
"Content-Type": contentType,
"Content-Length": String(chunkSize),
"Content-Range": `bytes ${start}-${end}/${totalSize}`,
"Accept-Ranges": "bytes",
"Cache-Control": "public, max-age=86400",
},
});
}
// No range — stream full file
const obj = await client.send(new GetObjectCommand({ Bucket: R2_BUCKET, Key: path }));
return new Response(obj.Body as ReadableStream, {
headers: {
"Content-Type": contentType,
"Content-Length": String(totalSize),
"Accept-Ranges": "bytes",
"Cache-Control": "public, max-age=86400",
},
});
} catch (e: any) {
if (e.name === "NoSuchKey") return c.json({ error: "Video not found" }, 404);
console.error("[Tube] Stream error:", e);
return c.json({ error: "Stream failed" }, 500);
}
});
// POST /api/videos — upload video (auth required)
routes.post("/api/videos", async (c) => {
const authToken = extractToken(c.req.raw.headers);
if (!authToken) return c.json({ error: "Authentication required" }, 401);
try { await verifyEncryptIDToken(authToken); } catch { return c.json({ error: "Invalid token" }, 401); }
const client = getS3();
if (!client) return c.json({ error: "R2 not configured" }, 503);
const formData = await c.req.formData();
const file = formData.get("video") as File | null;
if (!file) return c.json({ error: "video file required" }, 400);
const ext = file.name.substring(file.name.lastIndexOf(".")).toLowerCase();
if (!VIDEO_EXTENSIONS.has(ext)) return c.json({ error: `Unsupported format. Allowed: ${[...VIDEO_EXTENSIONS].join(", ")}` }, 400);
const key = formData.get("path")?.toString() || file.name;
const buffer = Buffer.from(await file.arrayBuffer());
await client.send(new PutObjectCommand({
Bucket: R2_BUCKET,
Key: key,
Body: buffer,
ContentType: file.type || "video/mp4",
ContentLength: buffer.length,
}));
return c.json({ ok: true, key, size: buffer.length, publicUrl: R2_PUBLIC_URL ? `${R2_PUBLIC_URL}/${key}` : undefined }, 201);
});
// GET /api/info — module info
routes.get("/api/info", (c) => {
return c.json({
module: "tube",
name: "rTube",
r2Configured: !!R2_ENDPOINT,
rtmpIngest: "rtmp://rtube.online:1936/live",
features: ["video-library", "hls-streaming", "rtmp-ingest"],
});
});
// GET /api/health
routes.get("/api/health", (c) => c.json({ ok: true }));
// ── Page route ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
title: `${space} — Tube | rSpace`,
moduleId: "tube",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "light",
styles: `<link rel="stylesheet" href="/modules/tube/tube.css">`,
body: `<folk-video-player space="${space}"></folk-video-player>`,
scripts: `<script type="module" src="/modules/tube/folk-video-player.js"></script>`,
}));
});
export const tubeModule: RSpaceModule = {
id: "tube",
name: "rTube",
icon: "\u{1F3AC}",
description: "Community video hosting & live streaming",
routes,
standaloneDomain: "rtube.online",
};