feat: initial rtube-online — community video platform

Self-hosted video recording, live streaming, and storage for rSpace
communities. Integrates nginx-rtmp for RTMP ingest/HLS playback,
R2 cloud storage for video archival, and Next.js landing page with
the r* ecosystem footer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-15 09:34:06 -07:00
commit 231c52b599
22 changed files with 4326 additions and 0 deletions

4
.env.example Normal file
View File

@ -0,0 +1,4 @@
R2_ENDPOINT=https://0e7b3338d5278ed1b148e6456b940913.r2.cloudflarestorage.com
R2_BUCKET=rtube-videos
R2_ACCESS_KEY_ID=your_access_key_id
R2_SECRET_ACCESS_KEY=your_secret_access_key

35
.gitignore vendored Normal file
View File

@ -0,0 +1,35 @@
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

37
Dockerfile Normal file
View File

@ -0,0 +1,37 @@
FROM node:20-alpine AS base
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# Dependencies stage
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml* package-lock.json* ./
RUN if [ -f pnpm-lock.yaml ]; then pnpm install --frozen-lockfile; else npm install; fi
# Build stage
FROM base AS builder
WORKDIR /app
COPY . .
RUN rm -rf node_modules .next
COPY --from=deps /app/node_modules ./node_modules
RUN npm run build
# Production stage
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

View File

@ -0,0 +1,89 @@
import { S3Client, GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'
import { NextRequest, NextResponse } from 'next/server'
function getS3Client() {
return new S3Client({
region: 'auto',
endpoint: process.env.R2_ENDPOINT!,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
})
}
const MIME_TYPES: Record<string, string> = {
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mov': 'video/mp4',
'.ogg': 'video/ogg',
'.m4v': 'video/mp4',
'.mkv': 'video/x-matroska',
'.avi': 'video/x-msvideo',
'.flv': 'video/x-flv',
}
export async function GET(
request: NextRequest,
{ params }: { params: { path: string[] } }
) {
try {
const s3 = getS3Client()
const bucket = process.env.R2_BUCKET || 'rtube-videos'
const key = params.path.join('/')
// Get file metadata
const headCommand = new HeadObjectCommand({ Bucket: bucket, Key: key })
const head = await s3.send(headCommand)
const fileSize = head.ContentLength || 0
const ext = key.substring(key.lastIndexOf('.')).toLowerCase()
const contentType = MIME_TYPES[ext] || head.ContentType || 'video/mp4'
const rangeHeader = request.headers.get('range')
if (rangeHeader) {
// Parse range header for video seeking
const parts = rangeHeader.replace('bytes=', '').split('-')
const start = parseInt(parts[0], 10)
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1
const getCommand = new GetObjectCommand({
Bucket: bucket,
Key: key,
Range: `bytes=${start}-${end}`,
})
const response = await s3.send(getCommand)
const body = response.Body as ReadableStream
return new NextResponse(body as unknown as BodyInit, {
status: 206,
headers: {
'Content-Type': contentType,
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': String(end - start + 1),
'Cache-Control': 'public, max-age=31536000',
},
})
} else {
const getCommand = new GetObjectCommand({ Bucket: bucket, Key: key })
const response = await s3.send(getCommand)
const body = response.Body as ReadableStream
return new NextResponse(body as unknown as BodyInit, {
headers: {
'Content-Type': contentType,
'Content-Length': String(fileSize),
'Accept-Ranges': 'bytes',
'Cache-Control': 'public, max-age=31536000',
},
})
}
} catch (error) {
if ((error as { name?: string }).name === 'NoSuchKey') {
return NextResponse.json({ error: 'File not found' }, { status: 404 })
}
const message = error instanceof Error ? error.message : 'Unknown error'
return NextResponse.json({ error: message }, { status: 500 })
}
}

41
app/api/videos/route.ts Normal file
View File

@ -0,0 +1,41 @@
import { S3Client, ListObjectsV2Command } from '@aws-sdk/client-s3'
import { NextResponse } from 'next/server'
const VIDEO_EXTENSIONS = new Set(['.mp4', '.mkv', '.webm', '.mov', '.avi', '.wmv', '.flv', '.m4v'])
function getS3Client() {
return new S3Client({
region: 'auto',
endpoint: process.env.R2_ENDPOINT!,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
})
}
export async function GET() {
try {
const s3 = getS3Client()
const bucket = process.env.R2_BUCKET || 'rtube-videos'
const command = new ListObjectsV2Command({ Bucket: bucket })
const response = await s3.send(command)
const videos = (response.Contents || [])
.filter((obj) => {
const ext = (obj.Key || '').substring((obj.Key || '').lastIndexOf('.')).toLowerCase()
return VIDEO_EXTENSIONS.has(ext)
})
.map((obj) => ({
name: obj.Key!,
size: obj.Size || 0,
}))
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
return NextResponse.json(videos)
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
return NextResponse.json({ error: message }, { status: 500 })
}
}

194
app/demo/page.tsx Normal file
View File

@ -0,0 +1,194 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import Link from 'next/link'
interface Video {
name: string
size: number
}
function formatSize(bytes: number): string {
if (!bytes) return ''
const units = ['B', 'KB', 'MB', 'GB']
let i = 0
let b = bytes
while (b >= 1024 && i < units.length - 1) {
b /= 1024
i++
}
return `${b.toFixed(1)} ${units[i]}`
}
function getIcon(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() || ''
if (['mp4', 'webm', 'mov'].includes(ext)) return '\uD83C\uDFAC'
if (['mkv', 'avi', 'wmv', 'flv'].includes(ext)) return '\u26A0\uFE0F'
return '\uD83D\uDCC4'
}
function isPlayable(filename: string): boolean {
const ext = filename.split('.').pop()?.toLowerCase() || ''
return ['mp4', 'webm', 'mov', 'ogg', 'm4v'].includes(ext)
}
export default function DemoPage() {
const [videos, setVideos] = useState<Video[]>([])
const [filteredVideos, setFilteredVideos] = useState<Video[]>([])
const [currentVideo, setCurrentVideo] = useState<string | null>(null)
const [search, setSearch] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const videoRef = useRef<HTMLVideoElement>(null)
useEffect(() => {
fetch('/api/videos')
.then((res) => {
if (!res.ok) throw new Error('Failed to load videos')
return res.json()
})
.then((data) => {
setVideos(data)
setFilteredVideos(data)
setLoading(false)
})
.catch((err) => {
setError(err.message)
setLoading(false)
})
}, [])
useEffect(() => {
const q = search.toLowerCase()
setFilteredVideos(videos.filter((v) => v.name.toLowerCase().includes(q)))
}, [search, videos])
function playVideo(key: string) {
setCurrentVideo(key)
}
function copyLink() {
if (!currentVideo) return
navigator.clipboard.writeText(`${window.location.origin}/api/v/${encodeURIComponent(currentVideo)}`)
}
const ext = currentVideo?.split('.').pop()?.toLowerCase() || ''
const playable = currentVideo ? isPlayable(currentVideo) : false
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-white">
{/* Nav */}
<nav className="border-b border-slate-700/50 backdrop-blur-sm">
<div className="max-w-[1400px] mx-auto px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Link href="/" className="flex items-center gap-2">
<div className="w-8 h-8 bg-gradient-to-br from-red-500 to-pink-600 rounded-lg flex items-center justify-center font-bold text-white text-sm">
rT
</div>
<span className="font-semibold text-lg">rTube</span>
</Link>
<span className="text-slate-500 ml-2">/ Video Library</span>
</div>
<Link
href="/live"
className="text-sm px-4 py-2 bg-red-600 hover:bg-red-500 rounded-lg transition-colors font-medium"
>
Go Live
</Link>
</div>
</nav>
<div className="max-w-[1400px] mx-auto px-6 py-8">
<h1 className="text-3xl font-bold text-center mb-8 text-red-400">Video Library</h1>
<div className="grid grid-cols-1 lg:grid-cols-[300px_1fr] gap-8">
{/* Sidebar */}
<div className="bg-slate-800/50 rounded-2xl p-4 border border-slate-700/50 max-h-[80vh] overflow-y-auto">
<input
type="text"
placeholder="Search videos..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full px-4 py-3 bg-black/30 border border-slate-700 rounded-lg text-white placeholder-slate-500 mb-4 text-sm focus:outline-none focus:border-red-500"
/>
<h2 className="text-xs uppercase tracking-wider text-slate-500 mb-3">Library</h2>
{loading && <p className="text-slate-500 text-sm p-4">Loading videos...</p>}
{error && <p className="text-red-400 text-sm p-4 bg-red-500/10 rounded-lg">Error: {error}</p>}
<ul className="space-y-1">
{filteredVideos.map((v) => (
<li
key={v.name}
onClick={() => playVideo(v.name)}
className={`flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-all text-sm
${currentVideo === v.name ? 'bg-red-500/20 border-l-2 border-red-500' : 'hover:bg-slate-700/50'}
${!isPlayable(v.name) ? 'opacity-60' : ''}`}
>
<span>{getIcon(v.name)}</span>
<span className="flex-1 truncate" title={v.name}>{v.name}</span>
<span className="text-xs text-slate-600 shrink-0">{formatSize(v.size)}</span>
</li>
))}
{!loading && filteredVideos.length === 0 && (
<li className="text-slate-500 text-sm p-4">No videos found</li>
)}
</ul>
</div>
{/* Player */}
<div>
<div className="bg-black rounded-2xl overflow-hidden aspect-video flex items-center justify-center">
{!currentVideo && (
<p className="text-slate-600 text-lg">Select a video to play</p>
)}
{currentVideo && !playable && (
<div className="text-center p-8">
<p className="text-4xl mb-4">{'\u26A0\uFE0F'}</p>
<p><strong>{ext.toUpperCase()}</strong> files cannot play in browsers</p>
<p className="text-sm text-slate-500 mt-2">Download to play locally, or re-record in MP4 format</p>
</div>
)}
{currentVideo && playable && (
<video
ref={videoRef}
key={currentVideo}
controls
autoPlay
preload="auto"
className="w-full h-full"
>
<source
src={`/api/v/${encodeURIComponent(currentVideo)}`}
type={ext === 'webm' ? 'video/webm' : 'video/mp4'}
/>
</video>
)}
</div>
{currentVideo && (
<div className="mt-4 bg-slate-800/50 rounded-xl p-4 border border-slate-700/50 flex items-center justify-between flex-wrap gap-4">
<p className="font-medium">{currentVideo}</p>
<div className="flex gap-2">
<a
href={`/api/v/${encodeURIComponent(currentVideo)}`}
download
className="px-4 py-2 bg-slate-700/50 hover:bg-slate-600/50 rounded-lg text-sm transition-colors"
>
Download
</a>
<button
onClick={copyLink}
className="px-4 py-2 bg-slate-700/50 hover:bg-slate-600/50 rounded-lg text-sm transition-colors"
>
Copy Link
</button>
</div>
</div>
)}
</div>
</div>
</div>
</div>
)
}

BIN
app/fonts/GeistMonoVF.woff Normal file

Binary file not shown.

BIN
app/fonts/GeistVF.woff Normal file

Binary file not shown.

27
app/globals.css Normal file
View File

@ -0,0 +1,27 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}

39
app/layout.tsx Normal file
View File

@ -0,0 +1,39 @@
import type { Metadata } from 'next'
import localFont from 'next/font/local'
import './globals.css'
const geistSans = localFont({
src: './fonts/GeistVF.woff',
variable: '--font-geist-sans',
weight: '100 900',
})
const geistMono = localFont({
src: './fonts/GeistMonoVF.woff',
variable: '--font-geist-mono',
weight: '100 900',
})
export const metadata: Metadata = {
title: 'rTube - Community Video Platform',
description: 'Self-hosted video recording, live streaming, and storage for rSpace communities. Local-first, zero-knowledge, community-owned.',
openGraph: {
title: 'rTube - Community Video Platform',
description: 'Self-hosted video recording, live streaming, and storage for rSpace communities.',
type: 'website',
url: 'https://rtube.online',
},
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en">
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{children}
</body>
</html>
)
}

179
app/live/page.tsx Normal file
View File

@ -0,0 +1,179 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
export default function LivePage() {
const videoRef = useRef<HTMLVideoElement>(null)
const [streamKey, setStreamKey] = useState('')
const [isPlaying, setIsPlaying] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showSetup, setShowSetup] = useState(false)
function startWatching() {
if (!streamKey.trim()) return
setError(null)
setIsPlaying(true)
}
useEffect(() => {
if (!isPlaying || !videoRef.current) return
const hlsUrl = `/hls/${streamKey}.m3u8`
async function initPlayer() {
const Hls = (await import('hls.js')).default
if (Hls.isSupported()) {
const hls = new Hls({
lowLatencyMode: true,
liveSyncDurationCount: 3,
})
hls.loadSource(hlsUrl)
hls.attachMedia(videoRef.current!)
hls.on(Hls.Events.MANIFEST_PARSED, () => {
videoRef.current?.play().catch(() => {})
})
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) {
setError('Stream not found or ended. Check the stream key and try again.')
setIsPlaying(false)
}
})
return () => hls.destroy()
} else if (videoRef.current?.canPlayType('application/vnd.apple.mpegurl')) {
// Safari native HLS
videoRef.current.src = hlsUrl
videoRef.current.play().catch(() => {})
} else {
setError('HLS playback is not supported in this browser.')
setIsPlaying(false)
}
}
const cleanup = initPlayer()
return () => {
cleanup?.then((fn) => fn?.())
}
}, [isPlaying, streamKey])
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-white">
{/* Nav */}
<nav className="border-b border-slate-700/50 backdrop-blur-sm">
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Link href="/" className="flex items-center gap-2">
<div className="w-8 h-8 bg-gradient-to-br from-red-500 to-pink-600 rounded-lg flex items-center justify-center font-bold text-white text-sm">
rT
</div>
<span className="font-semibold text-lg">rTube</span>
</Link>
<span className="text-slate-500 ml-2">/ Live</span>
</div>
<Link
href="/demo"
className="text-sm text-slate-300 hover:text-white transition-colors"
>
Video Library
</Link>
</div>
</nav>
<div className="max-w-4xl mx-auto px-6 py-12">
<h1 className="text-3xl font-bold text-center mb-8 text-red-400">Live Stream</h1>
{/* Stream viewer */}
{!isPlaying ? (
<div className="max-w-md mx-auto">
<div className="bg-slate-800/50 rounded-2xl p-8 border border-slate-700/50">
<h2 className="text-lg font-semibold mb-4">Watch a Stream</h2>
<p className="text-sm text-slate-400 mb-6">
Enter the stream key to watch a live broadcast from your community.
</p>
<input
type="text"
placeholder="Stream key (e.g. community-meeting)"
value={streamKey}
onChange={(e) => setStreamKey(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && startWatching()}
className="w-full px-4 py-3 bg-black/30 border border-slate-700 rounded-lg text-white placeholder-slate-500 mb-4 text-sm focus:outline-none focus:border-red-500"
/>
{error && (
<p className="text-red-400 text-sm mb-4 bg-red-500/10 rounded-lg p-3">{error}</p>
)}
<button
onClick={startWatching}
disabled={!streamKey.trim()}
className="w-full py-3 bg-red-600 hover:bg-red-500 disabled:bg-slate-700 disabled:text-slate-500 rounded-lg font-medium transition-colors"
>
Watch Stream
</button>
</div>
{/* OBS Setup */}
<div className="mt-8">
<button
onClick={() => setShowSetup(!showSetup)}
className="w-full text-left bg-slate-800/30 rounded-2xl p-6 border border-slate-700/50 hover:border-slate-600/50 transition-colors"
>
<h2 className="text-lg font-semibold mb-1 flex items-center justify-between">
Broadcaster Setup
<span className="text-slate-500 text-sm">{showSetup ? '\u25B2' : '\u25BC'}</span>
</h2>
<p className="text-sm text-slate-400">How to stream to rTube from OBS or FFmpeg</p>
</button>
{showSetup && (
<div className="mt-4 bg-slate-800/50 rounded-2xl p-6 border border-slate-700/50 space-y-4 text-sm">
<div>
<h3 className="font-medium text-red-400 mb-2">OBS Studio</h3>
<ol className="text-slate-400 space-y-2 list-decimal list-inside">
<li>Open <strong>Settings &rarr; Stream</strong></li>
<li>Set Service to <strong>Custom</strong></li>
<li>Server: <code className="bg-black/30 px-2 py-0.5 rounded text-slate-300">rtmp://rtube.online/live</code></li>
<li>Stream Key: choose any key (e.g. <code className="bg-black/30 px-2 py-0.5 rounded text-slate-300">community-meeting</code>)</li>
<li>Click <strong>Start Streaming</strong></li>
</ol>
</div>
<div>
<h3 className="font-medium text-red-400 mb-2">FFmpeg</h3>
<code className="block bg-black/30 p-3 rounded-lg text-slate-300 text-xs overflow-x-auto">
ffmpeg -i input.mp4 -c:v libx264 -preset veryfast -c:a aac -f flv rtmp://rtube.online/live/your-key
</code>
</div>
</div>
)}
</div>
</div>
) : (
<div>
<div className="bg-black rounded-2xl overflow-hidden aspect-video mb-4">
<video
ref={videoRef}
controls
autoPlay
playsInline
className="w-full h-full"
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="w-3 h-3 bg-red-500 rounded-full animate-pulse" />
<span className="font-medium">LIVE</span>
<span className="text-slate-500">Stream: {streamKey}</span>
</div>
<button
onClick={() => setIsPlaying(false)}
className="px-4 py-2 bg-slate-700/50 hover:bg-slate-600/50 rounded-lg text-sm transition-colors"
>
Leave Stream
</button>
</div>
</div>
)}
</div>
</div>
)
}

200
app/page.tsx Normal file
View File

@ -0,0 +1,200 @@
import Link from 'next/link'
export default function Home() {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-white">
{/* Nav */}
<nav className="border-b border-slate-700/50 backdrop-blur-sm">
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-gradient-to-br from-red-500 to-pink-600 rounded-lg flex items-center justify-center font-bold text-white text-sm">
rT
</div>
<span className="font-semibold text-lg">rTube</span>
</div>
<div className="flex items-center gap-4">
<Link
href="/demo"
className="text-sm text-slate-300 hover:text-white transition-colors"
>
Video Library
</Link>
<Link
href="/live"
className="text-sm px-4 py-2 bg-red-600 hover:bg-red-500 rounded-lg transition-colors font-medium"
>
Go Live
</Link>
</div>
</div>
</nav>
{/* Hero */}
<section className="max-w-6xl mx-auto px-6 pt-20 pb-16">
<div className="text-center max-w-3xl mx-auto">
<h1 className="text-5xl font-bold mb-6 bg-gradient-to-r from-red-400 via-pink-300 to-orange-300 bg-clip-text text-transparent">
Community Video Platform
</h1>
<p className="text-xl text-slate-300 mb-8 leading-relaxed">
Self-hosted video recording, live streaming, and storage for your rSpace community.
No corporate surveillance. No algorithmic feeds. Just your community&apos;s content.
</p>
<div className="flex items-center justify-center gap-4">
<Link
href="/demo"
className="px-6 py-3 bg-slate-700 hover:bg-slate-600 rounded-xl text-lg font-medium transition-all border border-slate-600"
>
Browse Videos
</Link>
<Link
href="/live"
className="px-6 py-3 bg-red-600 hover:bg-red-500 rounded-xl text-lg font-medium transition-all shadow-lg shadow-red-900/30"
>
Start Streaming
</Link>
</div>
</div>
</section>
{/* How it Works */}
<section className="max-w-6xl mx-auto px-6 py-16">
<h2 className="text-3xl font-bold text-center mb-12">How It Works</h2>
<div className="grid md:grid-cols-3 gap-8">
{/* Record */}
<div className="bg-slate-800/50 rounded-2xl p-6 border border-slate-700/50">
<div className="w-12 h-12 bg-red-500/20 rounded-xl flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<circle cx="12" cy="12" r="10" strokeWidth={2} />
<circle cx="12" cy="12" r="4" fill="currentColor" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">Record</h3>
<p className="text-slate-400 text-sm leading-relaxed">
Stream directly from OBS, browser, or any RTMP-compatible tool. Your content
goes straight to your community&apos;s server &mdash; no third-party platforms involved.
</p>
</div>
{/* Stream */}
<div className="bg-slate-800/50 rounded-2xl p-6 border border-slate-700/50">
<div className="w-12 h-12 bg-pink-500/20 rounded-xl flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-pink-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">Stream Live</h3>
<p className="text-slate-400 text-sm leading-relaxed">
Community members watch in real-time via HLS adaptive streaming.
Low-latency delivery through your own nginx-rtmp server with automatic
quality adaptation.
</p>
</div>
{/* Archive */}
<div className="bg-slate-800/50 rounded-2xl p-6 border border-slate-700/50">
<div className="w-12 h-12 bg-orange-500/20 rounded-xl flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-orange-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">Archive & Store</h3>
<p className="text-slate-400 text-sm leading-relaxed">
Completed streams are automatically converted to MP4 and archived to
R2 cloud storage. Browse, search, and replay your community&apos;s entire
video library anytime.
</p>
</div>
</div>
</section>
{/* Features */}
<section className="max-w-6xl mx-auto px-6 py-16">
<div className="bg-slate-800/30 rounded-2xl border border-slate-700/50 p-8">
<h2 className="text-2xl font-bold mb-8 text-center">Built for Communities</h2>
<div className="grid md:grid-cols-2 gap-6 max-w-3xl mx-auto">
<div className="flex items-start gap-3">
<span className="text-red-400 text-lg mt-0.5">&#9679;</span>
<div>
<h4 className="font-medium mb-1">Self-Hosted</h4>
<p className="text-sm text-slate-400">Your server, your data. No YouTube, no Twitch, no corporate middlemen.</p>
</div>
</div>
<div className="flex items-start gap-3">
<span className="text-pink-400 text-lg mt-0.5">&#9679;</span>
<div>
<h4 className="font-medium mb-1">RTMP Ingest</h4>
<p className="text-sm text-slate-400">Standard RTMP protocol &mdash; works with OBS, Streamlabs, FFmpeg, and more.</p>
</div>
</div>
<div className="flex items-start gap-3">
<span className="text-orange-400 text-lg mt-0.5">&#9679;</span>
<div>
<h4 className="font-medium mb-1">R2 Cloud Storage</h4>
<p className="text-sm text-slate-400">Cloudflare R2 for cost-effective, globally distributed video storage.</p>
</div>
</div>
<div className="flex items-start gap-3">
<span className="text-amber-400 text-lg mt-0.5">&#9679;</span>
<div>
<h4 className="font-medium mb-1">HLS Playback</h4>
<p className="text-sm text-slate-400">Adaptive bitrate streaming works on every device and browser.</p>
</div>
</div>
<div className="flex items-start gap-3">
<span className="text-rose-400 text-lg mt-0.5">&#9679;</span>
<div>
<h4 className="font-medium mb-1">Auto-Archive</h4>
<p className="text-sm text-slate-400">Streams are automatically converted to MP4 and uploaded when they end.</p>
</div>
</div>
<div className="flex items-start gap-3">
<span className="text-fuchsia-400 text-lg mt-0.5">&#9679;</span>
<div>
<h4 className="font-medium mb-1">Community-Scoped</h4>
<p className="text-sm text-slate-400">Each rSpace community gets its own video library and streaming channel.</p>
</div>
</div>
</div>
</div>
</section>
{/* CTA */}
<section className="max-w-6xl mx-auto px-6 py-16 text-center">
<h2 className="text-3xl font-bold mb-4">Ready to take back your video?</h2>
<p className="text-slate-400 mb-8 max-w-lg mx-auto">
Browse the community video library or start a live stream for your rSpace.
</p>
<Link
href="/demo"
className="inline-block px-8 py-4 bg-red-600 hover:bg-red-500 rounded-xl text-lg font-medium transition-all shadow-lg shadow-red-900/30"
>
Explore the Library
</Link>
</section>
{/* Footer */}
<footer className="border-t border-slate-700/50 py-8">
<div className="max-w-6xl mx-auto px-6">
<div className="flex flex-wrap items-center justify-center gap-4 text-sm text-slate-500 mb-4">
<span className="font-medium text-slate-400">r* Ecosystem</span>
<a href="https://rspace.online" className="hover:text-slate-300 transition-colors">🌌 rSpace</a>
<a href="https://rmaps.online" className="hover:text-slate-300 transition-colors">🗺 rMaps</a>
<a href="https://rnotes.online" className="hover:text-slate-300 transition-colors">📝 rNotes</a>
<a href="https://rvote.online" className="hover:text-slate-300 transition-colors">🗳 rVote</a>
<a href="https://rfunds.online" className="hover:text-slate-300 transition-colors">💰 rFunds</a>
<a href="https://rtrips.online" className="hover:text-slate-300 transition-colors"> rTrips</a>
<a href="https://rcart.online" className="hover:text-slate-300 transition-colors">🛒 rCart</a>
<a href="https://rwallet.online" className="hover:text-slate-300 transition-colors">💼 rWallet</a>
<a href="https://rfiles.online" className="hover:text-slate-300 transition-colors">📁 rFiles</a>
<a href="https://rcal.jeffemmett.com" className="hover:text-slate-300 transition-colors">📅 rCal</a>
<a href="https://rtube.online" className="hover:text-slate-300 transition-colors font-medium text-slate-300">📹 rTube</a>
<a href="https://rnetwork.online" className="hover:text-slate-300 transition-colors">🌐 rNetwork</a>
</div>
<p className="text-center text-xs text-slate-600">
Part of the r* ecosystem collaborative tools for communities.
</p>
</div>
</footer>
</div>
)
}

84
docker-compose.yml Normal file
View File

@ -0,0 +1,84 @@
services:
rtube:
build:
context: .
dockerfile: Dockerfile
container_name: rtube
restart: unless-stopped
environment:
- R2_ENDPOINT=${R2_ENDPOINT}
- R2_BUCKET=${R2_BUCKET:-rtube-videos}
- R2_ACCESS_KEY_ID=${R2_ACCESS_KEY_ID}
- R2_SECRET_ACCESS_KEY=${R2_SECRET_ACCESS_KEY}
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
read_only: true
tmpfs:
- /tmp
labels:
- "traefik.enable=true"
- "traefik.http.routers.rtube.rule=Host(`rtube.online`) || Host(`www.rtube.online`)"
- "traefik.http.routers.rtube.entrypoints=web,websecure"
- "traefik.http.services.rtube.loadbalancer.server.port=3000"
- "traefik.docker.network=traefik-public"
networks:
- traefik-public
- rtube-internal
nginx-rtmp:
image: tiangolo/nginx-rtmp:latest
container_name: rtube-rtmp
restart: unless-stopped
ports:
- "1935:1935"
volumes:
- ./nginx-rtmp/nginx.conf:/etc/nginx/nginx.conf:ro
- rtmp-recordings:/recordings
- rtmp-hls:/hls
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
labels:
- "traefik.enable=true"
- "traefik.http.routers.rtube-hls.rule=Host(`rtube.online`) && PathPrefix(`/hls`)"
- "traefik.http.routers.rtube-hls.entrypoints=web,websecure"
- "traefik.http.services.rtube-hls.loadbalancer.server.port=8080"
- "traefik.docker.network=traefik-public"
networks:
- traefik-public
- rtube-internal
archive-worker:
build:
context: .
dockerfile: nginx-rtmp/Dockerfile.archive
container_name: rtube-archive
restart: unless-stopped
environment:
- R2_BUCKET=${R2_BUCKET:-rtube-videos}
volumes:
- rtmp-recordings:/recordings
- /root/.config/rclone:/root/.config/rclone:ro
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
networks:
- rtube-internal
depends_on:
- nginx-rtmp
volumes:
rtmp-recordings:
rtmp-hls:
networks:
traefik-public:
external: true
rtube-internal:
driver: bridge

6
next.config.mjs Normal file
View File

@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
};
export default nextConfig;

View File

@ -0,0 +1,18 @@
FROM python:3.11-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg curl unzip && \
curl -O https://downloads.rclone.org/rclone-current-linux-amd64.zip && \
unzip rclone-current-linux-amd64.zip && \
mv rclone-*-linux-amd64/rclone /usr/local/bin/ && \
rm -rf rclone-* && \
apt-get clean && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir flask gunicorn
WORKDIR /app
COPY nginx-rtmp/scripts/archive-worker.py .
EXPOSE 8081
CMD ["gunicorn", "--bind", "0.0.0.0:8081", "--workers", "2", "--timeout", "300", "archive-worker:app"]

72
nginx-rtmp/nginx.conf Normal file
View File

@ -0,0 +1,72 @@
worker_processes auto;
rtmp_auto_push on;
events {
worker_connections 1024;
}
rtmp {
server {
listen 1935;
chunk_size 4096;
application live {
live on;
record all;
record_path /recordings;
record_suffix -%Y-%m-%d-%H%M%S.flv;
record_unique on;
# Convert to HLS
hls on;
hls_path /hls;
hls_fragment 3;
hls_playlist_length 60;
hls_cleanup on;
# On stream end - trigger archive
on_publish_done http://archive-worker:8081/archive;
# Allow publishing from anywhere (use stream key for security)
allow publish all;
allow play all;
}
}
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
# Docker internal DNS resolver
resolver 127.0.0.11 valid=30s ipv6=off;
server {
listen 8080;
# HLS playback
location /hls {
types {
application/vnd.apple.mpegurl m3u8;
video/mp2t ts;
}
root /;
add_header Cache-Control no-cache;
add_header Access-Control-Allow-Origin *;
}
# Stream stats
location /stat {
rtmp_stat all;
rtmp_stat_stylesheet stat.xsl;
}
# Simple status page
location / {
return 200 '{"status":"ok","service":"rtube-streaming"}';
add_header Content-Type application/json;
}
}
}

View File

@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""
Archive worker - converts completed streams to MP4 and uploads to R2.
Ported from streaming-server archive-worker.py, using rtube-videos bucket.
"""
import os
import subprocess
import time
import threading
from flask import Flask
app = Flask(__name__)
RECORDINGS_DIR = "/recordings"
R2_BUCKET = os.environ.get("R2_BUCKET", "rtube-videos")
ARCHIVE_PREFIX = "streams"
def upload_to_r2(filepath):
"""Convert FLV to MP4 and upload to R2."""
filename = os.path.basename(filepath)
mp4_path = filepath.replace(".flv", ".mp4")
print(f"Converting {filename} to MP4...")
try:
subprocess.run(
[
"ffmpeg", "-i", filepath,
"-c", "copy",
"-movflags", "+faststart",
mp4_path,
],
check=True,
capture_output=True,
)
dest = f"r2:{R2_BUCKET}/{ARCHIVE_PREFIX}/"
print(f"Uploading {os.path.basename(mp4_path)} to {dest}...")
subprocess.run(
["rclone", "copy", mp4_path, dest],
check=True,
)
print(f"Uploaded: {mp4_path}")
# Cleanup local files
os.remove(filepath)
os.remove(mp4_path)
print("Cleaned up local files")
except Exception as e:
print(f"Error processing {filename}: {e}")
def process_recordings():
"""Process any pending FLV recordings."""
time.sleep(5) # Wait for file to finish writing
for f in os.listdir(RECORDINGS_DIR):
if f.endswith(".flv"):
filepath = os.path.join(RECORDINGS_DIR, f)
# Check if file is still being written
size1 = os.path.getsize(filepath)
time.sleep(2)
size2 = os.path.getsize(filepath)
if size1 == size2: # File is complete
upload_to_r2(filepath)
@app.route("/archive", methods=["POST", "GET"])
def archive():
"""Called when stream ends."""
print("Stream ended, processing recordings...")
thread = threading.Thread(target=process_recordings)
thread.start()
return "OK", 200
@app.route("/health")
def health():
return "OK", 200
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8081)

3140
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "rtube-online",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.700.0",
"hls.js": "^1.5.0",
"next": "14.2.35",
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}

8
postcss.config.mjs Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

12
tailwind.config.ts Normal file
View File

@ -0,0 +1,12 @@
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {},
},
plugins: [],
}
export default config

27
tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"downlevelIteration": true,
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}