/** * 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> { 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: ``, body: ``, scripts: ``, })); }); export const tubeModule: RSpaceModule = { id: "tube", name: "rTube", icon: "\u{1F3AC}", description: "Community video hosting & live streaming", routes, standaloneDomain: "rtube.online", };