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:
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user