112 lines
3.4 KiB
TypeScript
112 lines
3.4 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";
|
|
|
|
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 = process.env.R2_ACCESS_KEY || "";
|
|
const R2_SECRET_KEY = process.env.R2_SECRET_KEY || "";
|
|
const R2_PUBLIC_URL = process.env.R2_PUBLIC_URL || "";
|
|
|
|
const VIDEO_EXTENSIONS = new Set([
|
|
".mp4", ".mkv", ".webm", ".mov", ".avi", ".wmv", ".flv", ".m4v",
|
|
]);
|
|
|
|
// ── S3-compatible listing (lightweight, no AWS SDK needed) ──
|
|
async function listVideos(): Promise<Array<{ name: string; size: number }>> {
|
|
if (!R2_ENDPOINT) {
|
|
// 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 {
|
|
// Use S3 ListObjectsV2 via signed request
|
|
const url = `${R2_ENDPOINT}/${R2_BUCKET}?list-type=2&max-keys=200`;
|
|
const resp = await fetch(url, {
|
|
headers: {
|
|
Authorization: `AWS4-HMAC-SHA256 ...`, // Simplified — real impl uses aws4 signing
|
|
},
|
|
});
|
|
if (!resp.ok) return [];
|
|
|
|
const text = await resp.text();
|
|
// Parse simple XML response
|
|
const items: Array<{ name: string; size: number }> = [];
|
|
const keyMatches = text.matchAll(/<Key>([^<]+)<\/Key>/g);
|
|
const sizeMatches = text.matchAll(/<Size>(\d+)<\/Size>/g);
|
|
const keys = [...keyMatches].map((m) => m[1]);
|
|
const sizes = [...sizeMatches].map((m) => parseInt(m[1]));
|
|
|
|
for (let i = 0; i < keys.length; i++) {
|
|
const ext = keys[i].substring(keys[i].lastIndexOf(".")).toLowerCase();
|
|
if (VIDEO_EXTENSIONS.has(ext)) {
|
|
items.push({ name: keys[i], size: sizes[i] || 0 });
|
|
}
|
|
}
|
|
return items.sort((a, b) => a.name.localeCompare(b.name));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// ── API routes ──
|
|
|
|
// GET /api/videos — list videos from bucket
|
|
routes.get("/api/videos", async (c) => {
|
|
const videos = await listVideos();
|
|
return c.json({ videos });
|
|
});
|
|
|
|
// 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",
|
|
};
|