Initial commit — StreamStack v1

Five-service streaming platform: auth, catalogue, streaming, ingest, thumbnailer.
Includes React frontend served by nginx, NATS JetStream event bus, aiobotocore
async S3, PyAV video metadata + thumbnail extraction, service-to-service JWT auth,
and a full unit + e2e test suite.
This commit is contained in:
2026-05-04 22:16:39 +10:00
commit 2309e9f43a
80 changed files with 6339 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>StreamStack</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
{
"name": "streamstack-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.8"
}
}
+49
View File
@@ -0,0 +1,49 @@
import { useEffect, useState } from 'react'
import { login } from './api'
import CatalogueGrid from './components/CatalogueGrid'
import VideoPlayer from './components/VideoPlayer'
export default function App() {
const [jwt, setJwt] = useState(null)
const [playing, setPlaying] = useState(null)
const [authError, setAuthError] = useState(null)
useEffect(() => {
login()
.then(setJwt)
.catch(e => setAuthError(e.message))
}, [])
async function handleRefreshJwt() {
try {
const token = await login()
setJwt(token)
return token
} catch (e) {
setAuthError(e.message)
throw e
}
}
if (authError) return <p className="status error">Auth error: {authError}</p>
if (!jwt) return <p className="status">Connecting</p>
return (
<>
<header>
<h1>StreamStack</h1>
</header>
<main>
<CatalogueGrid jwt={jwt} onPlay={setPlaying} />
</main>
{playing && (
<VideoPlayer
item={playing}
jwt={jwt}
onClose={() => setPlaying(null)}
onJwtExpired={handleRefreshJwt}
/>
)}
</>
)
}
+33
View File
@@ -0,0 +1,33 @@
const GUEST_EMAIL = import.meta.env.VITE_GUEST_EMAIL ?? 'guest@streamstack.local'
const GUEST_PASSWORD = import.meta.env.VITE_GUEST_PASSWORD ?? 'streamstack-guest'
export async function login() {
const body = new URLSearchParams({
username: GUEST_EMAIL,
password: GUEST_PASSWORD,
})
const resp = await fetch('/api/v1/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
})
if (!resp.ok) throw new Error(`Login failed: ${resp.status}`)
const data = await resp.json()
return data.access_token
}
export async function getCatalogue(offset = 0, limit = 50) {
const resp = await fetch(`/api/v1/catalogue/?offset=${offset}&limit=${limit}`)
if (!resp.ok) throw new Error(`Catalogue fetch failed: ${resp.status}`)
return resp.json()
}
export async function getStreamToken(mediaId, jwt) {
const resp = await fetch(`/api/v1/catalogue/${mediaId}/stream-token`, {
method: 'POST',
headers: { Authorization: `Bearer ${jwt}` },
})
if (!resp.ok) throw new Error(`Stream token failed: ${resp.status}`)
const data = await resp.json()
return data.stream_url
}
+28
View File
@@ -0,0 +1,28 @@
import { useEffect, useState } from 'react'
import { getCatalogue } from '../api'
import MediaCard from './MediaCard'
export default function CatalogueGrid({ jwt, onPlay }) {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
getCatalogue()
.then(data => setItems(data.items))
.catch(e => setError(e.message))
.finally(() => setLoading(false))
}, [])
if (loading) return <p className="status">Loading catalogue</p>
if (error) return <p className="status error">{error}</p>
if (items.length === 0) return <p className="status">No media available yet.</p>
return (
<div className="grid">
{items.map(item => (
<MediaCard key={item.id} item={item} onClick={onPlay} />
))}
</div>
)
}
+42
View File
@@ -0,0 +1,42 @@
function formatDuration(seconds) {
if (!seconds) return null
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = Math.floor(seconds % 60)
if (h > 0) return `${h}h ${m}m`
if (m > 0) return `${m}m ${s}s`
return `${s}s`
}
const TYPE_LABELS = {
movie: 'Movie',
tv_series: 'TV',
youtube_show: 'YouTube',
media: 'Media',
}
export default function MediaCard({ item, onClick }) {
return (
<div className="card" onClick={() => onClick(item)}>
{item.thumbnail_s3_key ? (
<img
className="card-thumb"
src={`/api/v1/catalogue/${item.id}/thumbnail`}
alt={item.title}
onError={e => { e.target.style.display = 'none' }}
/>
) : (
<div className="card-thumb card-thumb-placeholder" />
)}
<div className="card-body">
<span className="card-badge">{TYPE_LABELS[item.media_type] ?? item.media_type}</span>
<p className="card-title">{item.title}</p>
{item.director && <p className="card-sub">{item.director}</p>}
{item.show_name && <p className="card-sub">{item.show_name}</p>}
{formatDuration(item.duration_seconds) && (
<p className="card-sub">{formatDuration(item.duration_seconds)}</p>
)}
</div>
</div>
)
}
+41
View File
@@ -0,0 +1,41 @@
import { useEffect, useRef, useState } from 'react'
import { getStreamToken } from '../api'
export default function VideoPlayer({ item, jwt, onClose }) {
const [src, setSrc] = useState(null)
const [error, setError] = useState(null)
const videoRef = useRef(null)
useEffect(() => {
getStreamToken(item.id, jwt)
.then(url => setSrc(url))
.catch(e => setError(e.message))
}, [item.id, jwt])
useEffect(() => {
if (src && videoRef.current) {
videoRef.current.play().catch(() => {})
videoRef.current.requestFullscreen?.().catch(() => {})
}
}, [src])
return (
<div className="player-overlay" onClick={onClose}>
<div className="player-box" onClick={e => e.stopPropagation()}>
<button className="player-close" onClick={onClose}></button>
<h2>{item.title}</h2>
{error && <p className="error">{error}</p>}
{src ? (
<video
ref={videoRef}
src={src}
controls
className="player-video"
/>
) : (
!error && <p>Loading</p>
)}
</div>
</div>
)
}
+134
View File
@@ -0,0 +1,134 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: #111;
color: #eee;
min-height: 100vh;
}
header {
padding: 1rem 2rem;
border-bottom: 1px solid #333;
}
header h1 {
font-size: 1.5rem;
letter-spacing: 0.05em;
}
main {
padding: 2rem;
}
.status {
text-align: center;
padding: 4rem;
color: #888;
}
.error { color: #f66; }
/* Catalogue grid */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1.25rem;
}
/* Media card */
.card {
background: #1e1e1e;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0,0,0,0.6);
}
.card-thumb {
width: 100%;
aspect-ratio: 16/9;
object-fit: cover;
display: block;
}
.card-thumb-placeholder {
background: #2a2a2a;
}
.card-body {
padding: 0.75rem;
}
.card-badge {
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
background: #333;
padding: 2px 6px;
border-radius: 4px;
color: #aaa;
}
.card-title {
font-size: 0.9rem;
font-weight: 600;
margin-top: 0.4rem;
line-height: 1.3;
}
.card-sub {
font-size: 0.75rem;
color: #888;
margin-top: 0.2rem;
}
/* Video player overlay */
.player-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.player-box {
background: #1a1a1a;
border-radius: 10px;
padding: 1.5rem;
width: min(90vw, 960px);
position: relative;
}
.player-close {
position: absolute;
top: 0.75rem;
right: 0.75rem;
background: none;
border: none;
color: #aaa;
font-size: 1.2rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
}
.player-close:hover { color: #fff; }
.player-box h2 {
margin-bottom: 1rem;
font-size: 1.2rem;
}
.player-video {
width: 100%;
border-radius: 6px;
background: #000;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:8080',
},
},
})