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:
commit
231c52b599
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 → 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'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's server — 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'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">●</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">●</span>
|
||||
<div>
|
||||
<h4 className="font-medium mb-1">RTMP Ingest</h4>
|
||||
<p className="text-sm text-slate-400">Standard RTMP protocol — 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">●</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">●</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">●</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">●</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Reference in New Issue