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
+14
View File
@@ -0,0 +1,14 @@
.env
.venv/
__pycache__/
*.pyc
*.pyo
.pytest_cache/
.coverage
htmlcov/
dist/
*.egg-info/
.ruff_cache/
keys/
*.pem
testdata/
+112
View File
@@ -0,0 +1,112 @@
# StreamStack Architecture
## Services
| Service | Replicas | Backing stores | Responsibility |
|---------|----------|----------------|----------------|
| **auth** | 2 | Postgres, NATS KV | User accounts, JWT issue/refresh/revoke |
| **catalogue** | 2 | Postgres, NATS pub | Media metadata CRUD, stream token requests |
| **streaming** | 2 | NATS KV, S3 | Token issuance, byte-range video delivery |
| **ingest** | 2 | S3, (catalogue HTTP) | Upload video, extract metadata/thumbnail, register in catalogue |
| **nginx** | 1 | — | Reverse proxy + React SPA |
## Infrastructure
| Component | Purpose |
|-----------|---------|
| **Postgres** | Persistent store for user accounts (auth) and media metadata (catalogue) |
| **NATS JetStream KV** | Short-lived stream tokens (1h TTL); revoked-token list for JWT blacklisting |
| **S3 / MinIO** | Binary storage — `media/` bucket for video files, `thumbnails/` bucket for JPEG thumbnails |
---
## Request flows
### Login
```
Browser → nginx → auth
auth reads Postgres (verify credentials)
auth writes nothing to NATS
auth returns access_token (JWT, RS256, 30min) + refresh_token (7 days)
```
### Browse catalogue
```
Browser → nginx → catalogue
catalogue reads Postgres (published media items)
returns list of metadata (title, duration, thumbnail_s3_key, etc.)
no NATS, no S3
```
### Request a stream token
```
Browser → nginx → catalogue POST /catalogue/{id}/stream-token
catalogue reads Postgres → gets s3_key + size_bytes for the item
catalogue → streaming POST /stream/token {media_id, s3_key, size_bytes}
streaming verifies JWT (public key, local)
streaming writes NATS KV: token → "media_id|user_id|timestamp|s3_key|size_bytes"
streaming returns {stream_url: "/api/v1/stream/<token>"}
catalogue returns stream_url to browser
```
Token TTL: 1 hour. After that, NATS discards it automatically.
### Play video (each range request)
```
Browser → nginx → streaming GET /stream/<token> Range: bytes=X-Y
streaming reads NATS KV (resolve token → s3_key + size_bytes)
streaming → S3 GET object with byte range (aiobotocore, fully async)
streams bytes back to browser
no Postgres, no catalogue HTTP call
```
The browser sends many range requests for a single video. Each one costs only a NATS lookup + S3 range-get.
### Ingest a video (admin only)
```
curl/frontend → nginx → ingest POST /ingest/upload (multipart)
ingest verifies JWT (admin role required)
ingest → S3 upload file → media/{uuid}.ext
ingest → S3 head_object → size_bytes
ingest runs PyAV (in threadpool):
- reads S3 via range-gets → extracts duration, codec, width, height, fps
- decodes first video frame → JPEG → S3 thumbnails/{uuid}.jpg
ingest → catalogue POST /catalogue/ {s3_key, size_bytes, metadata...}
catalogue writes Postgres
catalogue publishes NATS: catalogue.events.media.published
returns catalogue item JSON
```
---
## JWT flow
Auth uses **RS256** (asymmetric). The private key signs tokens; all other services hold only the public key and verify locally — no auth HTTP call on every request.
Revoked tokens are stored as keys in a NATS KV bucket (`revoked-tokens`). Streaming checks this bucket on token issue, not on every range request.
---
## Data ownership
```
Postgres auth users, hashed passwords, roles
catalogue media items, all metadata fields
NATS KV streaming stream tokens (s3_key + size_bytes embedded)
auth revoked JWT list
S3 ingest video files → media/
ingest thumbnails → thumbnails/
(read) streaming reads media/ for range delivery
(read) ingest/PyAV reads media/ for metadata extraction
```
---
## Inter-service HTTP calls
| Caller | Callee | When |
|--------|--------|------|
| catalogue | streaming | Stream token request — passes s3_key + size_bytes |
| ingest | catalogue | After upload — registers the media item |
All other cross-service communication is either direct DB access (own service only) or NATS pub/sub. Services do **not** query each other's databases.
+31
View File
@@ -0,0 +1,31 @@
# syntax=docker/dockerfile:1.4
ARG SERVICE=streaming
# ── Stage 1: Build ────────────────────────────────────────────────────────────
FROM git.unkin.net/unkin/almalinux9-base:20260308 AS builder
RUN dnf install -y jellyfin-ffmpeg-bin && dnf clean all
WORKDIR /app
COPY pyproject.toml uv.lock* ./
COPY src/ src/
RUN uv sync --frozen --no-dev --python 3.12
# ── Stage 2: Runtime ──────────────────────────────────────────────────────────
FROM git.unkin.net/unkin/almalinux9-base:20260308 AS runtime
RUN dnf install -y jellyfin-ffmpeg-bin && dnf clean all
WORKDIR /app
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app/src /app/src
ARG SERVICE=streaming
ENV SERVICE=${SERVICE} \
PATH="/app/.venv/bin:$PATH" \
PYTHONPATH="/app/src"
EXPOSE 8000
COPY --chmod=755 scripts/entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
+21
View File
@@ -0,0 +1,21 @@
# syntax=docker/dockerfile:1.4
# Stage 1: build the React frontend
FROM node:22-alpine AS frontend-build
WORKDIR /app
COPY frontend/package*.json ./
RUN npm install
COPY frontend/ .
ARG VITE_GUEST_EMAIL=guest@streamstack.local
ARG VITE_GUEST_PASSWORD=streamstack-guest
RUN VITE_GUEST_EMAIL=$VITE_GUEST_EMAIL \
VITE_GUEST_PASSWORD=$VITE_GUEST_PASSWORD \
npm run build
# Stage 2: nginx serves the frontend and proxies API calls
FROM nginx:1.26-alpine
COPY --from=frontend-build /app/dist /usr/share/nginx/html
COPY nginx/conf.d/streamstack.conf /etc/nginx/conf.d/default.conf
+14
View File
@@ -0,0 +1,14 @@
FROM git.unkin.net/unkin/almalinux9-base:20260308
RUN dnf install -y \
gcc gcc-c++ make ffmpeg-devel libpq-devel && dnf clean all
WORKDIR /app
COPY pyproject.toml uv.lock* ./
COPY src/ src/
COPY tests/ tests/
COPY testdata/ testdata/
RUN uv sync --frozen --extra dev --python 3.12
ENV PYTHONPATH="/app/src" PATH="/app/.venv/bin:$PATH"
+30
View File
@@ -0,0 +1,30 @@
.PHONY: test lint lint-fix up down up-test down-test migrate migration
test:
uv run --extra dev pytest tests/ -v --cov=src/streamstack --cov-report=term-missing
lint:
uv run --extra lint ruff check src/ tests/
uv run --extra lint ruff format --check src/ tests/
lint-fix:
uv run --extra lint ruff check --fix src/ tests/
uv run --extra lint ruff format src/ tests/
up:
docker compose up -d --build
down:
docker compose down -v
up-test:
docker compose -f docker-compose.test.yml up --build --abort-on-container-exit
down-test:
docker compose -f docker-compose.test.yml down -v
migrate:
uv run alembic upgrade head
migration:
uv run alembic revision --autogenerate -m "$(MSG)"
+41
View File
@@ -0,0 +1,41 @@
welcome to stream stack.
this project is to build a media streaming service comprised of a number of microservices and multiple frontends (desktop, mobile, admin). the aim is that every component is highly available and load balanced. state is shared between all processes through NATS, and persistent data is stored in pgsql or s3 (depending on the data). the backends should all be build using fastapi. each backend service should be able to run independently (should it be one pypi package that we enable features for different modules, or a different pypi package for each system?)
the frontend services should be in a fast and responsive language that will consume the fastapi services (react maybe?). there should be a "router" service that the frontend talks to, which proxies connections to the appropriate backend, or should be put different services on different dns addresses?
question: can we stream media from s3? will that enable skipping forward/backwards?
ensure there are unit tests for all file (in tests/)
add a makefile that tests the unit tests (make test) using uvx (so we dont need to install any requirements permamently)
add makefile test for linting with ruff
add Dockerfile to run the streamstack (with booleans to enable different microservices)
- this should use git.unkin.net/unkin/almalinux9-base:latest to build, then the uv container (dhi.io/uv:0.11) to run
add docker-compose for e22 testing of the stack (with makefile targets to start/stop)
required projects:
- https://github.com/pyav-org/pyav
- https://github.com/fastapi/fastapi
- https://github.com/nats-io/nats.py
phase 1:
- build a backend microservice that can read media files with ffmpeg (pyav) from s3 and stream them. the url to stream the media should not include the name of the media. the url should be openable in mpv for testing.
phase 2:
- build a microservice that presents the media catalogue. this will be used by the frontend later to list media available.
phase 3:
- build auth microservice. it should be a jwt provider. when a user autheticates, they have a jwt kept somewhere that is passed to each microservice for each request. each microservice should then verify the jwt against the auth microservice.
phase 4:
- import microservice, for importing video into s3, adding to catalogue, finding metadata (thumbnail, actors, etc)
phase 5:
- simple react frontend (this is just for testing. no auth. just show catalogue and when you click on an item, play that video)
- the frontend should be its own container, so that it can be run in a DMZ
additional requirements:
keep track of where a user is up to in a given video, so that when they replay it, it starts from a few seconds before where they stopped.
when streaming video, send bursts of video to the user so that it caches on the client side
+16
View File
@@ -0,0 +1,16 @@
# TODO
- Transcode MKV uploads to MP4 during ingest — browsers (Firefox/Chrome) cannot natively play MKV containers, so Jellyfish-style uploads fail to load in the video player.
- IMDB metadata microservice — subscribe to `catalogue.events.media.published` (durable consumer `"imdb-fetcher"`), look up title/year against IMDB API, patch catalogue with enriched metadata (rating, genre, plot, cast).
- Subtitle fetcher microservice — subscribe to `catalogue.events.media.published` (durable consumer `"subtitle-fetcher"`), fetch subtitles (e.g. OpenSubtitles API), store as `.vtt` in S3, update catalogue with subtitle_s3_key. Frontend `<video>` supports `<track>` elements for native subtitle display.
## TV show metadata identification
For a file like `Clarkson's.Farm.S01E01.Tractoring.WEBRip-1080p.mp4`, metadata can be identified via:
- **Filename parsing** — extract show name, season, episode number, and episode title from the filename using a regex (e.g. `S(\d+)E(\d+)` pattern). The ingest service or a dedicated parser microservice could do this automatically at upload time, pre-filling `show_name`, `season`, `episode`, `episode_title` fields so the user doesn't have to type them.
- **TheTVDB API** — given `show_name` + `season` + `episode`, look up the canonical title, air date, plot, guest cast, network, and a high-quality episode thumbnail. Free API key available. Subscribe to `catalogue.events.media.published` as a durable consumer `"tvdb-fetcher"`.
- **TMDB (The Movie Database)** — also covers TV series (`/tv/{series_id}/season/{n}/episode/{n}`). Has episode stills, show banners, cast photos. Free API key.
- **IMDb / Cinemagoer** — Python library (`cinemagoer`, formerly IMDbPY) that scrapes IMDb data without an API key. Slower but no key required. IMDb series ID can be cross-referenced from TheTVDB.
- **Video container metadata** — MKV/MP4 files sometimes embed title, show name, season/episode in container tags (readable via PyAV `container.metadata`). Worth checking before hitting external APIs — already have the file open during ingest.
- **Suggested flow**: parse filename → check container tags → query TheTVDB with (show_name, season, episode) → fall back to TMDB → patch catalogue via service JWT.
+38
View File
@@ -0,0 +1,38 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = postgresql+asyncpg://streamstack:streamstack@localhost:5432/streamstack
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+45
View File
@@ -0,0 +1,45 @@
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import create_async_engine
from streamstack.core.config import settings
from streamstack.core.db import Base
import streamstack.auth.models # noqa: F401
import streamstack.catalogue.models # noqa: F401
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
engine = create_async_engine(settings.database_url, poolclass=pool.NullPool)
async with engine.connect() as connection:
await connection.run_sync(do_run_migrations)
await engine.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())
+185
View File
@@ -0,0 +1,185 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: streamstack
POSTGRES_PASSWORD: streamstack
POSTGRES_DB: streamstack
healthcheck:
test: ["CMD-SHELL", "pg_isready -U streamstack"]
interval: 5s
retries: 10
nats:
image: nats:2.10-alpine
command: ["-js", "-m", "8222"]
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8222/healthz"]
interval: 5s
retries: 10
minio:
image: quay.io/minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 5s
retries: 10
minio-init:
image: quay.io/minio/mc:latest
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 minioadmin minioadmin &&
mc mb --ignore-existing local/media &&
mc mb --ignore-existing local/thumbnails
"
auth:
build:
context: .
args:
SERVICE: auth
environment:
SERVICE: auth
DATABASE_URL: postgresql+asyncpg://streamstack:streamstack@postgres:5432/streamstack
NATS_URL: nats://nats:4222
JWT_PRIVATE_KEY_PATH: /run/jwt-keys/private.pem
JWT_PUBLIC_KEY_PATH: /run/jwt-keys/public.pem
JWT_ALGORITHM: RS256
JWT_EXPIRE_MINUTES: "30"
JWT_REFRESH_EXPIRE_DAYS: "7"
volumes:
- ./tests/e2e/keys:/run/jwt-keys:ro
depends_on:
postgres:
condition: service_healthy
nats:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8000/v1/health"]
interval: 10s
retries: 5
catalogue:
build:
context: .
args:
SERVICE: catalogue
environment:
SERVICE: catalogue
DATABASE_URL: postgresql+asyncpg://streamstack:streamstack@postgres:5432/streamstack
NATS_URL: nats://nats:4222
S3_ENDPOINT_URL: http://minio:9000
S3_ACCESS_KEY: minioadmin
S3_SECRET_KEY: minioadmin
S3_BUCKET_MEDIA: media
AUTH_SERVICE_URL: http://auth:8000
STREAMING_SERVICE_URL: http://streaming:8000
depends_on:
postgres:
condition: service_healthy
nats:
condition: service_healthy
auth:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8000/v1/health"]
interval: 10s
retries: 5
streaming:
build:
context: .
args:
SERVICE: streaming
environment:
SERVICE: streaming
NATS_URL: nats://nats:4222
S3_ENDPOINT_URL: http://minio:9000
S3_ACCESS_KEY: minioadmin
S3_SECRET_KEY: minioadmin
S3_BUCKET_MEDIA: media
AUTH_SERVICE_URL: http://auth:8000
depends_on:
nats:
condition: service_healthy
minio:
condition: service_healthy
auth:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8000/v1/health"]
interval: 10s
retries: 5
ingest:
build:
context: .
args:
SERVICE: ingest
environment:
SERVICE: ingest
NATS_URL: nats://nats:4222
S3_ENDPOINT_URL: http://minio:9000
S3_ACCESS_KEY: minioadmin
S3_SECRET_KEY: minioadmin
S3_BUCKET_MEDIA: media
S3_BUCKET_THUMBNAILS: thumbnails
AUTH_SERVICE_URL: http://auth:8000
CATALOGUE_SERVICE_URL: http://catalogue:8000
depends_on:
nats:
condition: service_healthy
minio:
condition: service_healthy
auth:
condition: service_healthy
catalogue:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8000/v1/health"]
interval: 10s
retries: 5
nginx:
build:
context: .
dockerfile: Dockerfile.nginx
depends_on:
- streaming
- catalogue
- auth
- ingest
e2e-tests:
build:
context: .
dockerfile: Dockerfile.test
environment:
GATEWAY_URL: http://nginx:80
S3_ENDPOINT_URL: http://minio:9000
S3_ACCESS_KEY: minioadmin
S3_SECRET_KEY: minioadmin
S3_BUCKET_MEDIA: media
depends_on:
nginx:
condition: service_started
streaming:
condition: service_healthy
catalogue:
condition: service_healthy
auth:
condition: service_healthy
ingest:
condition: service_healthy
minio-init:
condition: service_completed_successfully
command: ["uv", "run", "--extra", "dev", "pytest", "tests/e2e/", "-v"]
+232
View File
@@ -0,0 +1,232 @@
services:
# ── Infrastructure ──────────────────────────────────────────────────────────
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: streamstack
POSTGRES_PASSWORD: streamstack
POSTGRES_DB: streamstack
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U streamstack"]
interval: 5s
timeout: 5s
retries: 10
nats:
image: nats:2.10-alpine
command: ["-js", "-m", "8222"]
ports:
- "4222:4222"
- "8222:8222"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8222/healthz"]
interval: 5s
retries: 10
minio:
image: quay.io/minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 5s
retries: 10
minio-init:
image: quay.io/minio/mc:latest
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 minioadmin minioadmin &&
mc mb --ignore-existing local/media &&
mc mb --ignore-existing local/thumbnails
"
# ── Application services ────────────────────────────────────────────────────
auth:
build:
context: .
args:
SERVICE: auth
environment:
SERVICE: auth
DATABASE_URL: postgresql+asyncpg://streamstack:streamstack@postgres:5432/streamstack
NATS_URL: nats://nats:4222
JWT_PRIVATE_KEY_PATH: /run/jwt/private.pem
JWT_PUBLIC_KEY_PATH: /run/jwt/public.pem
JWT_ALGORITHM: RS256
JWT_EXPIRE_MINUTES: "30"
JWT_REFRESH_EXPIRE_DAYS: "7"
volumes:
- ./tests/e2e/keys/private.pem:/run/jwt/private.pem:ro,z
- ./tests/e2e/keys/public.pem:/run/jwt/public.pem:ro,z
depends_on:
postgres:
condition: service_healthy
nats:
condition: service_healthy
deploy:
replicas: 2
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8000/v1/health"]
interval: 10s
retries: 5
catalogue:
build:
context: .
args:
SERVICE: catalogue
environment:
SERVICE: catalogue
SERVICE_NAME: catalogue
SERVICE_SECRET: svc-catalogue-secret
DATABASE_URL: postgresql+asyncpg://streamstack:streamstack@postgres:5432/streamstack
NATS_URL: nats://nats:4222
S3_ENDPOINT_URL: http://minio:9000
S3_ACCESS_KEY: minioadmin
S3_SECRET_KEY: minioadmin
S3_BUCKET_MEDIA: media
AUTH_SERVICE_URL: http://auth:8000
STREAMING_SERVICE_URL: http://streaming:8000
depends_on:
postgres:
condition: service_healthy
nats:
condition: service_healthy
auth:
condition: service_healthy
deploy:
replicas: 2
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8000/v1/health"]
interval: 10s
retries: 5
streaming:
build:
context: .
args:
SERVICE: streaming
environment:
SERVICE: streaming
SERVICE_NAME: streaming
SERVICE_SECRET: svc-streaming-secret
NATS_URL: nats://nats:4222
S3_ENDPOINT_URL: http://minio:9000
S3_ACCESS_KEY: minioadmin
S3_SECRET_KEY: minioadmin
S3_BUCKET_MEDIA: media
AUTH_SERVICE_URL: http://auth:8000
CATALOGUE_SERVICE_URL: http://catalogue:8000
depends_on:
nats:
condition: service_healthy
minio:
condition: service_healthy
auth:
condition: service_healthy
catalogue:
condition: service_healthy
deploy:
replicas: 2
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8000/v1/health"]
interval: 10s
retries: 5
ingest:
build:
context: .
args:
SERVICE: ingest
environment:
SERVICE: ingest
SERVICE_NAME: ingest
SERVICE_SECRET: svc-ingest-secret
NATS_URL: nats://nats:4222
S3_ENDPOINT_URL: http://minio:9000
S3_ACCESS_KEY: minioadmin
S3_SECRET_KEY: minioadmin
S3_BUCKET_MEDIA: media
S3_BUCKET_THUMBNAILS: thumbnails
AUTH_SERVICE_URL: http://auth:8000
CATALOGUE_SERVICE_URL: http://catalogue:8000
depends_on:
nats:
condition: service_healthy
minio:
condition: service_healthy
auth:
condition: service_healthy
catalogue:
condition: service_healthy
deploy:
replicas: 2
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8000/v1/health"]
interval: 10s
retries: 5
thumbnailer:
build:
context: .
args:
SERVICE: thumbnailer
environment:
SERVICE: thumbnailer
SERVICE_NAME: thumbnailer
SERVICE_SECRET: svc-thumbnailer-secret
NATS_URL: nats://nats:4222
S3_ENDPOINT_URL: http://minio:9000
S3_ACCESS_KEY: minioadmin
S3_SECRET_KEY: minioadmin
S3_BUCKET_MEDIA: media
S3_BUCKET_THUMBNAILS: thumbnails
AUTH_SERVICE_URL: http://auth:8000
CATALOGUE_SERVICE_URL: http://catalogue:8000
depends_on:
nats:
condition: service_healthy
minio:
condition: service_healthy
catalogue:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8000/v1/health"]
interval: 10s
retries: 5
# ── Gateway ──────────────────────────────────────────────────────────────────
nginx:
build:
context: .
dockerfile: Dockerfile.nginx
ports:
- "8080:80"
depends_on:
- streaming
- catalogue
- auth
- ingest
volumes:
postgres_data:
minio_data:
+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',
},
},
})
+43
View File
@@ -0,0 +1,43 @@
upstream streaming_pool { server streaming:8000; }
upstream catalogue_pool { server catalogue:8000; }
upstream auth_pool { server auth:8000; }
upstream ingest_pool { server ingest:8000; }
server {
listen 80;
client_max_body_size 4G;
location /api/v1/stream/ {
proxy_pass http://streaming_pool/v1/stream/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_buffering off;
proxy_request_buffering off;
}
location /api/v1/catalogue/ {
proxy_pass http://catalogue_pool/v1/catalogue/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /api/v1/auth/ {
proxy_pass http://auth_pool/v1/auth/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /api/v1/ingest/ {
proxy_pass http://ingest_pool/v1/ingest/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_request_buffering off;
proxy_read_timeout 300s;
}
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
}
+62
View File
@@ -0,0 +1,62 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/streamstack"]
[project]
name = "streamstack"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"fastapi",
"uvicorn[standard]",
"pydantic-settings",
"av",
"nats-py",
"sqlalchemy[asyncio]",
"asyncpg",
"alembic",
"boto3",
"aiobotocore",
"pyjwt[crypto]",
"pwdlib[argon2]",
"httpx",
"python-multipart",
"pillow",
]
[project.optional-dependencies]
dev = [
"pytest",
"pytest-asyncio",
"pytest-cov",
"moto[s3]",
"aiosqlite",
]
lint = ["ruff"]
[project.scripts]
streamstack-streaming = "streamstack.streaming.app:main"
streamstack-catalogue = "streamstack.catalogue.app:main"
streamstack-auth = "streamstack.auth.app:main"
streamstack-ingest = "streamstack.ingest.app:main"
streamstack-thumbnailer = "streamstack.thumbnailer.app:main"
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
addopts = "--tb=short -q --ignore=tests/e2e"
markers = ["e2e: end-to-end tests requiring a running stack (deselect with -m 'not e2e')"]
[tool.ruff]
src = ["src"]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
ignore = [
"B008", # Depends() in argument defaults is standard FastAPI usage
]
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
set -e
exec "streamstack-${SERVICE:-streaming}" "$@"
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
set -e
mc alias set local "${S3_ENDPOINT_URL:-http://localhost:9000}" \
"${S3_ACCESS_KEY:-minioadmin}" "${S3_SECRET_KEY:-minioadmin}"
mc mb --ignore-existing local/media
mc mb --ignore-existing local/thumbnails
echo "MinIO buckets ready."
View File
View File
+53
View File
@@ -0,0 +1,53 @@
from contextlib import asynccontextmanager
import uvicorn
from fastapi import FastAPI
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from streamstack.auth.hasher import hash_password
from streamstack.auth.models import Base, User
from streamstack.auth.router import router
from streamstack.core import nats as nats_core
from streamstack.core.config import settings
from streamstack.core.db import SessionLocal, engine
from streamstack.core.nats import ensure_kv_bucket
@asynccontextmanager
async def lifespan(app: FastAPI):
try:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
except IntegrityError:
pass
nc = await nats_core.connect()
await ensure_kv_bucket(nc, "revoked-tokens")
await _seed_guest_user()
yield
await nats_core.disconnect()
async def _seed_guest_user() -> None:
async with SessionLocal() as db:
result = await db.execute(select(User).where(User.email == settings.guest_email))
if result.scalar_one_or_none() is None:
db.add(
User(
email=settings.guest_email,
hashed_password=hash_password(settings.guest_password),
roles=["viewer"],
)
)
try:
await db.commit()
except IntegrityError:
await db.rollback()
app = FastAPI(title="StreamStack Auth", lifespan=lifespan)
app.include_router(router, prefix="/v1")
def main():
uvicorn.run(app, host=settings.host, port=settings.port)
+12
View File
@@ -0,0 +1,12 @@
from pwdlib import PasswordHash
from pwdlib.hashers.argon2 import Argon2Hasher
_hasher = PasswordHash((Argon2Hasher(),))
def hash_password(plain: str) -> str:
return _hasher.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
return _hasher.verify(plain, hashed)
+18
View File
@@ -0,0 +1,18 @@
import uuid
from datetime import UTC, datetime
from sqlalchemy import JSON, Boolean, Column, DateTime, String
from sqlalchemy.dialects.postgresql import UUID
from streamstack.core.db import Base
class User(Base):
__tablename__ = "users"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email = Column(String(255), nullable=False, unique=True)
hashed_password = Column(String(255), nullable=False)
roles = Column(JSON, nullable=False, default=list)
is_active = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
+65
View File
@@ -0,0 +1,65 @@
from fastapi import APIRouter, Depends
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from streamstack.auth.schemas import (
RefreshRequest,
RevokeRequest,
TokenResponse,
UserCreate,
UserResponse,
)
from streamstack.auth.service import (
authenticate_user,
get_jwks,
get_user_profile,
refresh_user_token,
register_user,
revoke_user_token,
)
from streamstack.core.db import get_db
from streamstack.core.middleware import UserClaims, verify_jwt
from streamstack.core.nats import get_client as get_nats
router = APIRouter()
@router.get("/health")
async def health():
return {"status": "ok"}
@router.get("/auth/.well-known/jwks.json")
async def jwks():
return get_jwks()
@router.post("/auth/users/", response_model=UserResponse, status_code=201)
async def register(body: UserCreate, db: AsyncSession = Depends(get_db)):
return await register_user(db, get_nats(), body)
@router.post("/auth/token", response_model=TokenResponse)
async def login(
form: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db),
):
return await authenticate_user(db, form)
@router.post("/auth/token/refresh", response_model=TokenResponse)
async def refresh_token(body: RefreshRequest, db: AsyncSession = Depends(get_db)):
return await refresh_user_token(db, body)
@router.post("/auth/token/revoke", status_code=204)
async def revoke_token(body: RevokeRequest):
await revoke_user_token(get_nats(), body)
@router.get("/auth/users/me", response_model=UserResponse)
async def me(
current_user: UserClaims = Depends(verify_jwt),
db: AsyncSession = Depends(get_db),
):
return await get_user_profile(db, current_user)
+34
View File
@@ -0,0 +1,34 @@
import uuid
from datetime import datetime
from pydantic import BaseModel
class UserCreate(BaseModel):
email: str
password: str
roles: list[str] = ["viewer"]
class UserResponse(BaseModel):
id: uuid.UUID
email: str
roles: list[str]
is_active: bool
created_at: datetime
model_config = {"from_attributes": True}
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class RefreshRequest(BaseModel):
refresh_token: str
class RevokeRequest(BaseModel):
token: str
+133
View File
@@ -0,0 +1,133 @@
import contextlib
import json
import uuid
from pathlib import Path
import jwt
from fastapi import HTTPException
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from streamstack.auth.hasher import hash_password, verify_password
from streamstack.auth.models import User
from streamstack.auth.schemas import (
RefreshRequest,
RevokeRequest,
TokenResponse,
UserCreate,
UserResponse,
)
from streamstack.core.auth import create_access_token, create_refresh_token, decode_token
from streamstack.core.config import settings
from streamstack.core.middleware import UserClaims
from streamstack.core.nats import ensure_kv_bucket
def get_jwks() -> dict:
path = settings.jwt_public_key_path
if not path:
raise HTTPException(status_code=500, detail="Public key not configured")
key_data = Path(path).read_text()
return {"keys": [_pem_to_jwk(key_data)]}
def _pem_to_jwk(pem: str) -> dict:
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
from jwt.algorithms import RSAAlgorithm
key = serialization.load_pem_public_key(pem.encode(), backend=default_backend())
return json.loads(RSAAlgorithm.to_jwk(key))
async def register_user(db: AsyncSession, nc, body: UserCreate) -> UserResponse:
result = await db.execute(select(User).where(User.email == body.email))
if result.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Email already registered")
user = User(
email=body.email,
hashed_password=hash_password(body.password),
roles=body.roles,
)
db.add(user)
await db.commit()
await db.refresh(user)
with contextlib.suppress(Exception):
await nc.publish(
"auth.events.user.created",
json.dumps({"user_id": str(user.id), "email": user.email}).encode(),
)
return UserResponse.model_validate(user)
async def authenticate_user(db: AsyncSession, form: OAuth2PasswordRequestForm) -> TokenResponse:
result = await db.execute(select(User).where(User.email == form.username))
user = result.scalar_one_or_none()
if user is None or not verify_password(form.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid credentials")
if not user.is_active:
raise HTTPException(status_code=403, detail="Account disabled")
return TokenResponse(
access_token=create_access_token(str(user.id), user.email, user.roles),
refresh_token=create_refresh_token(str(user.id)),
)
async def refresh_user_token(db: AsyncSession, body: RefreshRequest) -> TokenResponse:
try:
payload = decode_token(body.refresh_token)
except jwt.InvalidTokenError as exc:
raise HTTPException(status_code=401, detail=f"Invalid refresh token: {exc}") from exc
if payload.get("type") != "refresh":
raise HTTPException(status_code=401, detail="Not a refresh token")
user = await db.get(User, uuid.UUID(payload["sub"]))
if user is None or not user.is_active:
raise HTTPException(status_code=401, detail="User not found or disabled")
return TokenResponse(
access_token=create_access_token(str(user.id), user.email, user.roles),
refresh_token=create_refresh_token(str(user.id)),
)
async def revoke_user_token(nc, body: RevokeRequest) -> None:
try:
payload = decode_token(body.token)
except jwt.ExpiredSignatureError:
payload = jwt.decode(
body.token,
options={"verify_exp": False},
algorithms=[settings.jwt_algorithm],
key=_get_public_key_for_decode(),
)
except jwt.InvalidTokenError as exc:
raise HTTPException(status_code=400, detail=f"Invalid token: {exc}") from exc
jti = payload.get("jti")
exp = payload.get("exp")
if not jti:
return
js = nc.jetstream()
try:
kv = await js.key_value("revoked-tokens")
except Exception:
await ensure_kv_bucket(nc, "revoked-tokens")
kv = await js.key_value("revoked-tokens")
await kv.put(jti, b"revoked")
await nc.publish(
"auth.events.token.revoked",
json.dumps({"jti": jti, "exp": exp}).encode(),
)
async def get_user_profile(db: AsyncSession, current_user: UserClaims) -> UserResponse:
user = await db.get(User, uuid.UUID(current_user.sub))
if user is None:
raise HTTPException(status_code=404, detail="User not found")
return UserResponse.model_validate(user)
def _get_public_key_for_decode() -> str:
path = settings.jwt_public_key_path
if not path:
raise RuntimeError("JWT_PUBLIC_KEY_PATH not configured")
return Path(path).read_text()
+32
View File
@@ -0,0 +1,32 @@
import asyncio
from contextlib import asynccontextmanager
import uvicorn
from fastapi import FastAPI
from streamstack.catalogue.models import Base
from streamstack.catalogue.router import router
from streamstack.core import nats as nats_core
from streamstack.core import service_auth
from streamstack.core.config import settings
from streamstack.core.db import engine
@asynccontextmanager
async def lifespan(app: FastAPI):
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await nats_core.connect()
await service_auth.init()
task = asyncio.create_task(service_auth.refresh_loop())
yield
task.cancel()
await nats_core.disconnect()
app = FastAPI(title="StreamStack Catalogue", lifespan=lifespan)
app.include_router(router, prefix="/v1")
def main():
uvicorn.run(app, host=settings.host, port=settings.port)
+64
View File
@@ -0,0 +1,64 @@
import uuid
from datetime import UTC, datetime
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, Integer, String, Text
from sqlalchemy.dialects.postgresql import UUID
from streamstack.core.db import Base
class MediaItem(Base):
__tablename__ = "media_items"
__mapper_args__ = {
"polymorphic_on": "media_type",
"polymorphic_identity": "media",
}
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
media_type = Column(String(50), nullable=False, default="media")
title = Column(Text, nullable=False)
description = Column(Text)
s3_key = Column(Text, nullable=False, unique=True)
bucket = Column(String(255), nullable=False, default="media")
content_type = Column(String(100), nullable=False, default="video/mp4")
duration_seconds = Column(Float)
size_bytes = Column(Integer)
width = Column(Integer)
height = Column(Integer)
fps = Column(Float)
codec = Column(String(50))
thumbnail_s3_key = Column(Text)
tags = Column(JSON, default=list)
created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
updated_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
is_published = Column(Boolean, nullable=False, default=False)
# Movie fields
director = Column(Text)
release_year = Column(Integer)
mpaa_rating = Column(String(10))
imdb_id = Column(String(20))
# TV series fields
show_name = Column(Text)
season = Column(Integer)
episode = Column(Integer)
episode_title = Column(Text)
network = Column(Text)
# YouTube show fields
youtube_channel_id = Column(String(100))
youtube_video_id = Column(String(50))
channel_name = Column(Text)
class Movie(MediaItem):
__mapper_args__ = {"polymorphic_identity": "movie"}
class TvSeries(MediaItem):
__mapper_args__ = {"polymorphic_identity": "tv_series"}
class YoutubeShow(MediaItem):
__mapper_args__ = {"polymorphic_identity": "youtube_show"}
+100
View File
@@ -0,0 +1,100 @@
import uuid
from typing import Annotated
from fastapi import APIRouter, Body, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from streamstack.catalogue.schemas import (
AnyMediaCreate,
AnyMediaResponse,
MediaItemUpdate,
MediaListResponse,
StreamTokenResponse,
)
from streamstack.catalogue.service import (
create_media_item,
delete_media_item,
get_media_item,
get_stream_token,
list_media_items,
update_media_item,
)
from streamstack.core.db import get_db
from streamstack.core.middleware import UserClaims, verify_jwt, verify_service_jwt
router = APIRouter()
@router.get("/health")
async def health():
return {"status": "ok"}
@router.get("/catalogue/", response_model=MediaListResponse)
async def list_media(
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
tag: str | None = Query(None),
media_type: str | None = Query(None),
db: AsyncSession = Depends(get_db),
):
return await list_media_items(db, offset, limit, tag, media_type)
@router.get("/catalogue/{media_id}", response_model=AnyMediaResponse)
async def get_media(media_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
return await get_media_item(db, media_id)
@router.post("/catalogue/", response_model=AnyMediaResponse, status_code=201)
async def create_media(
body: Annotated[AnyMediaCreate, Body()],
current_user: UserClaims = Depends(verify_jwt),
db: AsyncSession = Depends(get_db),
):
return await create_media_item(db, body, current_user)
@router.put("/catalogue/{media_id}", response_model=AnyMediaResponse)
async def update_media(
media_id: uuid.UUID,
body: MediaItemUpdate,
current_user: UserClaims = Depends(verify_jwt),
db: AsyncSession = Depends(get_db),
):
return await update_media_item(db, media_id, body, current_user)
@router.delete("/catalogue/{media_id}", status_code=204)
async def delete_media(
media_id: uuid.UUID,
current_user: UserClaims = Depends(verify_jwt),
db: AsyncSession = Depends(get_db),
):
await delete_media_item(db, media_id, current_user)
@router.patch("/catalogue/{media_id}/thumbnail", status_code=204)
async def set_thumbnail(
media_id: uuid.UUID,
body: Annotated[dict, Body()],
_: UserClaims = Depends(verify_service_jwt),
db: AsyncSession = Depends(get_db),
):
"""Internal endpoint — sets thumbnail_s3_key. Service JWT required."""
from streamstack.catalogue.models import MediaItem
from datetime import UTC, datetime
item = await db.get(MediaItem, media_id)
if item is not None:
item.thumbnail_s3_key = body.get("thumbnail_s3_key")
item.updated_at = datetime.now(UTC)
await db.commit()
@router.post("/catalogue/{media_id}/stream-token", response_model=StreamTokenResponse)
async def request_stream_token(
media_id: uuid.UUID,
current_user: UserClaims = Depends(verify_jwt),
db: AsyncSession = Depends(get_db),
):
return await get_stream_token(db, media_id, current_user)
+156
View File
@@ -0,0 +1,156 @@
import uuid
from datetime import datetime
from typing import Annotated, Literal
from pydantic import BaseModel, Field
class _MediaBase(BaseModel):
title: str
description: str | None = None
s3_key: str
bucket: str = "media"
content_type: str = "video/mp4"
duration_seconds: float | None = None
size_bytes: int | None = None
width: int | None = None
height: int | None = None
fps: float | None = None
codec: str | None = None
thumbnail_s3_key: str | None = None
tags: list[str] = []
is_published: bool = False
class _MediaResponse(_MediaBase):
id: uuid.UUID
media_type: str
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
# ── Movie ────────────────────────────────────────────────────────────────────
class MovieCreate(_MediaBase):
media_type: Literal["movie"] = "movie"
director: str | None = None
release_year: int | None = None
mpaa_rating: str | None = None
imdb_id: str | None = None
class MovieResponse(_MediaResponse):
media_type: Literal["movie"]
director: str | None = None
release_year: int | None = None
mpaa_rating: str | None = None
imdb_id: str | None = None
# ── TV Series ────────────────────────────────────────────────────────────────
class TvSeriesCreate(_MediaBase):
media_type: Literal["tv_series"] = "tv_series"
show_name: str | None = None
season: int | None = None
episode: int | None = None
episode_title: str | None = None
network: str | None = None
class TvSeriesResponse(_MediaResponse):
media_type: Literal["tv_series"]
show_name: str | None = None
season: int | None = None
episode: int | None = None
episode_title: str | None = None
network: str | None = None
# ── YouTube Show ─────────────────────────────────────────────────────────────
class YoutubeShowCreate(_MediaBase):
media_type: Literal["youtube_show"] = "youtube_show"
youtube_channel_id: str | None = None
youtube_video_id: str | None = None
channel_name: str | None = None
class YoutubeShowResponse(_MediaResponse):
media_type: Literal["youtube_show"]
youtube_channel_id: str | None = None
youtube_video_id: str | None = None
channel_name: str | None = None
# ── Generic fallback (base media_type) ───────────────────────────────────────
class MediaItemCreate(_MediaBase):
media_type: Literal["media"] = "media"
class MediaItemResponse(_MediaResponse):
media_type: Literal["media"]
# ── Discriminated unions ──────────────────────────────────────────────────────
AnyMediaCreate = Annotated[
MovieCreate | TvSeriesCreate | YoutubeShowCreate | MediaItemCreate,
Field(discriminator="media_type"),
]
AnyMediaResponse = Annotated[
MovieResponse | TvSeriesResponse | YoutubeShowResponse | MediaItemResponse,
Field(discriminator="media_type"),
]
# ── Shared update / list ──────────────────────────────────────────────────────
class MediaItemUpdate(BaseModel):
title: str | None = None
description: str | None = None
content_type: str | None = None
duration_seconds: float | None = None
size_bytes: int | None = None
width: int | None = None
height: int | None = None
fps: float | None = None
codec: str | None = None
thumbnail_s3_key: str | None = None
tags: list[str] | None = None
is_published: bool | None = None
# Movie
director: str | None = None
release_year: int | None = None
mpaa_rating: str | None = None
imdb_id: str | None = None
# TV series
show_name: str | None = None
season: int | None = None
episode: int | None = None
episode_title: str | None = None
network: str | None = None
# YouTube
youtube_channel_id: str | None = None
youtube_video_id: str | None = None
channel_name: str | None = None
class StreamTokenResponse(BaseModel):
stream_url: str
class MediaListResponse(BaseModel):
items: list[AnyMediaResponse]
total: int
offset: int
limit: int
+168
View File
@@ -0,0 +1,168 @@
import contextlib
import json
import uuid
from datetime import UTC, datetime
from typing import Annotated
import httpx
from fastapi import Body, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from streamstack.catalogue.models import MediaItem, Movie, TvSeries, YoutubeShow
from streamstack.catalogue.schemas import (
AnyMediaCreate,
AnyMediaResponse,
MediaItemResponse,
MediaItemUpdate,
MediaListResponse,
MovieResponse,
StreamTokenResponse,
TvSeriesResponse,
YoutubeShowResponse,
)
from streamstack.core import service_auth
from streamstack.core.config import settings
from streamstack.core.middleware import UserClaims
from streamstack.core.nats import get_client as get_nats
_ORM_CLASS = {
"movie": Movie,
"tv_series": TvSeries,
"youtube_show": YoutubeShow,
"media": MediaItem,
}
_SCHEMA_MAP = {
"movie": MovieResponse,
"tv_series": TvSeriesResponse,
"youtube_show": YoutubeShowResponse,
"media": MediaItemResponse,
}
def orm_to_response(item: MediaItem) -> AnyMediaResponse:
schema = _SCHEMA_MAP.get(item.media_type, MediaItemResponse)
return schema.model_validate(item)
async def list_media_items(
db: AsyncSession,
offset: int,
limit: int,
tag: str | None,
media_type: str | None,
) -> MediaListResponse:
stmt = select(MediaItem).where(MediaItem.is_published.is_(True))
if media_type:
stmt = stmt.where(MediaItem.media_type == media_type)
result = await db.execute(stmt)
all_items = result.scalars().all()
if tag:
all_items = [i for i in all_items if i.tags and tag in i.tags]
total = len(all_items)
items = all_items[offset : offset + limit]
return MediaListResponse(
items=[orm_to_response(i) for i in items],
total=total,
offset=offset,
limit=limit,
)
async def get_media_item(db: AsyncSession, media_id: uuid.UUID) -> AnyMediaResponse:
item = await db.get(MediaItem, media_id)
if item is None or not item.is_published:
raise HTTPException(status_code=404, detail="Media not found")
return orm_to_response(item)
async def create_media_item(
db: AsyncSession,
body: Annotated[AnyMediaCreate, Body()],
current_user: UserClaims,
) -> AnyMediaResponse:
if "admin" not in current_user.roles and "service" not in current_user.roles:
raise HTTPException(status_code=403, detail="Admin or service role required")
orm_class = _ORM_CLASS.get(body.media_type, MediaItem)
item = orm_class(**body.model_dump())
db.add(item)
await db.commit()
await db.refresh(item)
if item.is_published:
await _publish_media_event(item)
return orm_to_response(item)
async def update_media_item(
db: AsyncSession,
media_id: uuid.UUID,
body: MediaItemUpdate,
current_user: UserClaims,
) -> AnyMediaResponse:
if "admin" not in current_user.roles:
raise HTTPException(status_code=403, detail="Admin role required")
item = await db.get(MediaItem, media_id)
if item is None:
raise HTTPException(status_code=404, detail="Media not found")
was_published = item.is_published
for field, value in body.model_dump(exclude_none=True).items():
setattr(item, field, value)
item.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(item)
if not was_published and item.is_published:
await _publish_media_event(item)
return orm_to_response(item)
async def delete_media_item(
db: AsyncSession,
media_id: uuid.UUID,
current_user: UserClaims,
) -> None:
if "admin" not in current_user.roles:
raise HTTPException(status_code=403, detail="Admin role required")
item = await db.get(MediaItem, media_id)
if item is None:
raise HTTPException(status_code=404, detail="Media not found")
await db.delete(item)
await db.commit()
async def get_stream_token(
db: AsyncSession,
media_id: uuid.UUID,
current_user: UserClaims,
) -> StreamTokenResponse:
item = await db.get(MediaItem, media_id)
if item is None or not item.is_published:
raise HTTPException(status_code=404, detail="Media not found")
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{settings.streaming_service_url}/v1/stream/token",
json={
"media_id": str(media_id),
"user_id": current_user.sub,
"s3_key": item.s3_key,
"size_bytes": item.size_bytes or 0,
},
headers={"Authorization": f"Bearer {service_auth.get_token()}"},
)
resp.raise_for_status()
return StreamTokenResponse(**resp.json())
async def _publish_media_event(item: MediaItem) -> None:
with contextlib.suppress(Exception):
nc = get_nats()
payload = json.dumps(
{
"event": "media.published",
"media_id": str(item.id),
"title": item.title,
"s3_key": item.s3_key,
"media_type": item.media_type,
}
).encode()
await nc.publish("catalogue.events.media.published", payload)
View File
+51
View File
@@ -0,0 +1,51 @@
import uuid
from datetime import UTC, datetime, timedelta
from pathlib import Path
import jwt
from streamstack.core.config import settings
def _load_private_key() -> str:
path = settings.jwt_private_key_path
if not path:
raise RuntimeError("JWT_PRIVATE_KEY_PATH not configured")
return Path(path).read_text()
def _load_public_key() -> str:
path = settings.jwt_public_key_path
if not path:
raise RuntimeError("JWT_PUBLIC_KEY_PATH not configured")
return Path(path).read_text()
def create_access_token(sub: str, email: str, roles: list[str]) -> str:
now = datetime.now(UTC)
payload = {
"sub": sub,
"email": email,
"roles": roles,
"jti": str(uuid.uuid4()),
"iat": now,
"exp": now + timedelta(minutes=settings.jwt_expire_minutes),
}
return jwt.encode(payload, _load_private_key(), algorithm=settings.jwt_algorithm)
def create_refresh_token(sub: str) -> str:
now = datetime.now(UTC)
payload = {
"sub": sub,
"type": "refresh",
"jti": str(uuid.uuid4()),
"iat": now,
"exp": now + timedelta(days=settings.jwt_refresh_expire_days),
}
return jwt.encode(payload, _load_private_key(), algorithm=settings.jwt_algorithm)
def decode_token(token: str) -> dict:
public_key = _load_public_key()
return jwt.decode(token, public_key, algorithms=[settings.jwt_algorithm])
+45
View File
@@ -0,0 +1,45 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
# NATS
nats_url: str = "nats://localhost:4222"
# S3 / MinIO
s3_endpoint_url: str | None = None
s3_access_key: str | None = None
s3_secret_key: str | None = None
s3_bucket_media: str = "media"
s3_bucket_thumbnails: str = "thumbnails"
# Database
database_url: str = "postgresql+asyncpg://streamstack:streamstack@localhost:5432/streamstack"
# JWT / Auth
jwt_private_key_path: str | None = None
jwt_public_key_path: str | None = None
jwt_algorithm: str = "RS256"
jwt_expire_minutes: int = 30
jwt_refresh_expire_days: int = 7
# Guest account (used by the frontend for unauthenticated stream token issuance)
guest_email: str = "guest@streamstack.local"
guest_password: str = "streamstack-guest"
# Service identity (used for inter-service auth)
service_name: str = "unknown"
service_secret: str = "streamstack-service-secret"
# Inter-service URLs
auth_service_url: str = "http://localhost:8001"
streaming_service_url: str = "http://localhost:8002"
catalogue_service_url: str = "http://localhost:8003"
# Service host/port
host: str = "0.0.0.0"
port: int = 8000
settings = Settings()
+18
View File
@@ -0,0 +1,18 @@
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from streamstack.core.config import settings
engine = create_async_engine(settings.database_url, echo=False)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with SessionLocal() as session:
yield session
+74
View File
@@ -0,0 +1,74 @@
import functools
from dataclasses import dataclass
import httpx
import jwt
from fastapi import Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from streamstack.core.config import settings
from streamstack.core.nats import get_client as get_nats
_bearer = HTTPBearer()
@dataclass
class UserClaims:
sub: str
email: str
roles: list[str]
jti: str
@functools.lru_cache(maxsize=1)
def _fetch_jwks() -> dict:
resp = httpx.get(f"{settings.auth_service_url}/v1/auth/.well-known/jwks.json", timeout=5)
resp.raise_for_status()
return resp.json()
def _get_public_key():
jwks = _fetch_jwks()
return jwt.algorithms.RSAAlgorithm.from_jwk(jwks["keys"][0])
async def verify_jwt(
credentials: HTTPAuthorizationCredentials = Depends(_bearer),
) -> UserClaims:
token = credentials.credentials
try:
public_key = _get_public_key()
payload = jwt.decode(token, public_key, algorithms=[settings.jwt_algorithm])
except jwt.ExpiredSignatureError:
_fetch_jwks.cache_clear()
raise HTTPException(status_code=401, detail="Token expired") from None
except jwt.InvalidTokenError as exc:
raise HTTPException(status_code=401, detail=f"Invalid token: {exc}") from exc
jti = payload.get("jti")
if jti:
nc = get_nats()
js = nc.jetstream()
try:
kv = await js.key_value("revoked-tokens")
await kv.get(jti)
raise HTTPException(status_code=401, detail="Token has been revoked")
except HTTPException:
raise
except Exception:
pass
return UserClaims(
sub=payload["sub"],
email=payload.get("email", ""),
roles=payload.get("roles", []),
jti=jti or "",
)
async def verify_service_jwt(
claims: UserClaims = Depends(verify_jwt),
) -> UserClaims:
if "service" not in claims.roles:
raise HTTPException(status_code=403, detail="Service role required")
return claims
+47
View File
@@ -0,0 +1,47 @@
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
import nats
from nats.aio.client import Client as NATSClient
from nats.js.api import KeyValueConfig
from streamstack.core.config import settings
_nc: NATSClient | None = None
async def connect() -> NATSClient:
global _nc
_nc = await nats.connect(settings.nats_url)
return _nc
async def disconnect() -> None:
global _nc
if _nc is not None:
await _nc.drain()
_nc = None
def get_client() -> NATSClient:
if _nc is None:
raise RuntimeError("NATS not connected")
return _nc
async def ensure_kv_bucket(nc: NATSClient, bucket: str, ttl: int = 0) -> None:
"""Create a JetStream KV bucket if it doesn't already exist."""
js = nc.jetstream()
try:
await js.find_key_value(bucket)
except Exception:
await js.create_key_value(KeyValueConfig(bucket=bucket, ttl=ttl))
@asynccontextmanager
async def lifespan_nats() -> AsyncGenerator[NATSClient, None]:
nc = await connect()
try:
yield nc
finally:
await disconnect()
+94
View File
@@ -0,0 +1,94 @@
import io
from contextlib import asynccontextmanager
import aiobotocore.session
import boto3
from streamstack.core.config import settings
@asynccontextmanager
async def async_s3_client():
session = aiobotocore.session.get_session()
async with session.create_client(
"s3",
endpoint_url=settings.s3_endpoint_url,
aws_access_key_id=settings.s3_access_key,
aws_secret_access_key=settings.s3_secret_key,
region_name="us-east-1",
) as client:
yield client
def get_s3_client():
return boto3.client(
"s3",
endpoint_url=settings.s3_endpoint_url,
aws_access_key_id=settings.s3_access_key,
aws_secret_access_key=settings.s3_secret_key,
)
class S3SeekableFile(io.RawIOBase):
"""Seekable file-like object backed by S3 range-get requests.
Implements io.RawIOBase so it can be wrapped in io.BufferedReader
and passed directly to av.open() for PyAV demuxing without downloading
the entire file.
"""
def __init__(self, bucket: str, key: str, s3_client=None):
self._bucket = bucket
self._key = key
self._s3 = s3_client or get_s3_client()
head = self._s3.head_object(Bucket=bucket, Key=key)
self._size: int = head["ContentLength"]
self._pos: int = 0
def readable(self) -> bool:
return True
def seekable(self) -> bool:
return True
def tell(self) -> int:
return self._pos
def seek(self, offset: int, whence: int = io.SEEK_SET) -> int:
if whence == io.SEEK_SET:
self._pos = offset
elif whence == io.SEEK_CUR:
self._pos += offset
elif whence == io.SEEK_END:
self._pos = self._size + offset
else:
raise ValueError(f"Invalid whence value: {whence}")
self._pos = max(0, min(self._pos, self._size))
return self._pos
def readinto(self, b: bytearray) -> int:
if self._pos >= self._size:
return 0
size = len(b)
end = min(self._pos + size - 1, self._size - 1)
resp = self._s3.get_object(
Bucket=self._bucket,
Key=self._key,
Range=f"bytes={self._pos}-{end}",
)
data = resp["Body"].read()
n = len(data)
b[:n] = data
self._pos += n
return n
@property
def size(self) -> int:
return self._size
def open_s3_buffered(
bucket: str, key: str, buffer_size: int = 4 * 1024 * 1024
) -> io.BufferedReader:
"""Return a seekable BufferedReader over an S3 object."""
return io.BufferedReader(S3SeekableFile(bucket, key), buffer_size=buffer_size)
+47
View File
@@ -0,0 +1,47 @@
import asyncio
import logging
import httpx
from streamstack.core.config import settings
log = logging.getLogger(__name__)
_token: str | None = None
async def init() -> None:
"""Register this service's account with auth (idempotent) and obtain a JWT."""
global _token
email = f"{settings.service_name}@streamstack.internal"
async with httpx.AsyncClient() as client:
# 409 = already registered, that's fine
await client.post(
f"{settings.auth_service_url}/v1/auth/users/",
json={"email": email, "password": settings.service_secret, "roles": ["service"]},
)
resp = await client.post(
f"{settings.auth_service_url}/v1/auth/token",
data={"username": email, "password": settings.service_secret},
)
resp.raise_for_status()
_token = resp.json()["access_token"]
log.info("Service '%s' registered and authenticated", settings.service_name)
def get_token() -> str:
if _token is None:
raise RuntimeError("Service not authenticated — ensure service_auth.init() ran in lifespan")
return _token
async def refresh_loop(interval: int = 1500) -> None:
"""Refresh the service JWT every interval seconds (default 25 min for 30 min tokens)."""
while True:
await asyncio.sleep(interval)
try:
await init()
except Exception as exc:
log.warning("Service JWT refresh failed, will retry next cycle: %s", exc)
View File
+28
View File
@@ -0,0 +1,28 @@
import asyncio
from contextlib import asynccontextmanager
import uvicorn
from fastapi import FastAPI
from streamstack.core import nats as nats_core
from streamstack.core import service_auth
from streamstack.core.config import settings
from streamstack.ingest.router import router
@asynccontextmanager
async def lifespan(app: FastAPI):
await nats_core.connect()
await service_auth.init()
task = asyncio.create_task(service_auth.refresh_loop())
yield
task.cancel()
await nats_core.disconnect()
app = FastAPI(title="StreamStack Ingest", lifespan=lifespan)
app.include_router(router, prefix="/v1")
def main():
uvicorn.run(app, host=settings.host, port=settings.port)
+56
View File
@@ -0,0 +1,56 @@
from fastapi import APIRouter, Depends, Form, UploadFile
from fastapi.responses import JSONResponse
from streamstack.core.middleware import UserClaims, verify_jwt
from streamstack.ingest.schemas import IngestForm
from streamstack.ingest.service import ingest_upload
router = APIRouter()
@router.get("/health")
async def health():
return {"status": "ok"}
@router.post("/ingest/upload", status_code=201)
async def upload(
file: UploadFile,
media_type: str = Form("media"),
title: str = Form(...),
description: str | None = Form(None),
is_published: bool = Form(False),
director: str | None = Form(None),
release_year: int | None = Form(None),
mpaa_rating: str | None = Form(None),
imdb_id: str | None = Form(None),
show_name: str | None = Form(None),
season: int | None = Form(None),
episode: int | None = Form(None),
episode_title: str | None = Form(None),
network: str | None = Form(None),
youtube_channel_id: str | None = Form(None),
youtube_video_id: str | None = Form(None),
channel_name: str | None = Form(None),
current_user: UserClaims = Depends(verify_jwt),
):
form = IngestForm(
media_type=media_type,
title=title,
description=description,
is_published=is_published,
director=director,
release_year=release_year,
mpaa_rating=mpaa_rating,
imdb_id=imdb_id,
show_name=show_name,
season=season,
episode=episode,
episode_title=episode_title,
network=network,
youtube_channel_id=youtube_channel_id,
youtube_video_id=youtube_video_id,
channel_name=channel_name,
)
result = await ingest_upload(file, form, current_user)
return JSONResponse(content=result, status_code=201)
+26
View File
@@ -0,0 +1,26 @@
from typing import Literal
from pydantic import BaseModel
class IngestForm(BaseModel):
media_type: Literal["movie", "tv_series", "youtube_show", "media"] = "media"
title: str
description: str | None = None
is_published: bool = False
tags: list[str] = []
# Movie
director: str | None = None
release_year: int | None = None
mpaa_rating: str | None = None
imdb_id: str | None = None
# TV series
show_name: str | None = None
season: int | None = None
episode: int | None = None
episode_title: str | None = None
network: str | None = None
# YouTube
youtube_channel_id: str | None = None
youtube_video_id: str | None = None
channel_name: str | None = None
+75
View File
@@ -0,0 +1,75 @@
import asyncio
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from uuid import uuid4
import httpx
from fastapi import HTTPException, UploadFile
from streamstack.core import service_auth
from streamstack.core.config import settings
from streamstack.core.middleware import UserClaims
from streamstack.core.s3 import get_s3_client
from streamstack.ingest.schemas import IngestForm
from streamstack.streaming.pyav_utils import extract_metadata
_executor = ThreadPoolExecutor(max_workers=4)
_CONTENT_TYPE_MAP = {
".mp4": "video/mp4",
".mkv": "video/x-matroska",
".webm": "video/webm",
".avi": "video/x-msvideo",
".mov": "video/quicktime",
".m4v": "video/mp4",
".ts": "video/mp2t",
}
def _run_sync(fn):
loop = asyncio.get_event_loop()
return loop.run_in_executor(_executor, fn)
async def ingest_upload(
file: UploadFile,
form: IngestForm,
current_user: UserClaims,
) -> dict:
if "admin" not in current_user.roles:
raise HTTPException(status_code=403, detail="Admin role required")
ext = Path(file.filename or "video.mp4").suffix.lower()
s3_key = f"media/{uuid4()}{ext}"
content_type = _CONTENT_TYPE_MAP.get(ext, "application/octet-stream")
s3 = get_s3_client()
await _run_sync(lambda: s3.upload_fileobj(file.file, settings.s3_bucket_media, s3_key))
head = await _run_sync(lambda: s3.head_object(Bucket=settings.s3_bucket_media, Key=s3_key))
size_bytes: int = head["ContentLength"]
meta = await _run_sync(lambda: extract_metadata(settings.s3_bucket_media, s3_key))
video_stream = next((s for s in meta.get("streams", []) if s["type"] == "video"), None)
catalogue_payload = {
**form.model_dump(exclude_none=True),
"s3_key": s3_key,
"content_type": content_type,
"size_bytes": size_bytes,
"duration_seconds": meta.get("duration"),
"width": video_stream["width"] if video_stream else None,
"height": video_stream["height"] if video_stream else None,
"fps": video_stream["fps"] if video_stream else None,
"codec": video_stream["codec"] if video_stream else None,
}
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{settings.catalogue_service_url}/v1/catalogue/",
json=catalogue_payload,
headers={"Authorization": f"Bearer {service_auth.get_token()}"},
)
resp.raise_for_status()
return resp.json()
+30
View File
@@ -0,0 +1,30 @@
import asyncio
from contextlib import asynccontextmanager
import uvicorn
from fastapi import FastAPI
from streamstack.core import nats as nats_core
from streamstack.core import service_auth
from streamstack.core.config import settings
from streamstack.streaming.router import router
from streamstack.streaming.tokens import init_token_bucket
@asynccontextmanager
async def lifespan(app: FastAPI):
nc = await nats_core.connect()
await init_token_bucket(nc)
await service_auth.init()
task = asyncio.create_task(service_auth.refresh_loop())
yield
task.cancel()
await nats_core.disconnect()
app = FastAPI(title="StreamStack Streaming", lifespan=lifespan)
app.include_router(router, prefix="/v1")
def main():
uvicorn.run(app, host=settings.host, port=settings.port)
+42
View File
@@ -0,0 +1,42 @@
import io
import av
from streamstack.core.s3 import S3SeekableFile, get_s3_client
def open_s3_container(bucket: str, key: str) -> av.container.InputContainer:
"""Open an S3 object as a PyAV InputContainer with full seek support."""
s3 = get_s3_client()
raw = S3SeekableFile(bucket, key, s3_client=s3)
buffered = io.BufferedReader(raw, buffer_size=4 * 1024 * 1024)
return av.open(buffered)
def extract_metadata(bucket: str, key: str) -> dict:
"""Extract video metadata from an S3 object without downloading the full file."""
with open_s3_container(bucket, key) as container:
result = {
"duration": float(container.duration / av.time_base) if container.duration else None,
"streams": [],
}
for stream in container.streams.video:
result["streams"].append(
{
"type": "video",
"codec": stream.codec_context.name,
"width": stream.codec_context.width,
"height": stream.codec_context.height,
"fps": float(stream.average_rate) if stream.average_rate else None,
}
)
for stream in container.streams.audio:
result["streams"].append(
{
"type": "audio",
"codec": stream.codec_context.name,
"sample_rate": stream.codec_context.sample_rate,
"channels": stream.codec_context.channels,
}
)
return result
+36
View File
@@ -0,0 +1,36 @@
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel
from streamstack.core.middleware import UserClaims, verify_service_jwt
from streamstack.core.nats import get_client as get_nats
from streamstack.streaming.service import build_stream_response, issue_token
router = APIRouter()
class TokenRequest(BaseModel):
media_id: str
user_id: str
s3_key: str
size_bytes: int
@router.get("/health")
async def health():
return {"status": "ok"}
@router.post("/stream/token")
async def issue_stream_token(
body: TokenRequest,
_: UserClaims = Depends(verify_service_jwt),
):
nc = get_nats()
token = await issue_token(nc, body.media_id, body.user_id, body.s3_key, body.size_bytes)
return {"stream_url": f"/api/v1/stream/{token}"}
@router.get("/stream/{token}")
async def stream_media(token: str, request: Request):
nc = get_nats()
return await build_stream_response(nc, token, request.headers.get("range"))
+78
View File
@@ -0,0 +1,78 @@
from fastapi import HTTPException
from fastapi.responses import StreamingResponse
from streamstack.core.config import settings
from streamstack.core.s3 import async_s3_client
from streamstack.streaming.tokens import create_stream_token, resolve_stream_token
CHUNK_SIZE = 10 * 1024 * 1024 # 10 MiB
_CONTENT_TYPES = {
"mp4": "video/mp4",
"webm": "video/webm",
"ogg": "video/ogg",
"mkv": "video/x-matroska",
}
async def issue_token(nc, media_id: str, user_sub: str, s3_key: str, size_bytes: int) -> str:
return await create_stream_token(nc, media_id, user_sub, s3_key, size_bytes)
async def build_stream_response(nc, token: str, range_header: str | None) -> StreamingResponse:
result = await resolve_stream_token(nc, token)
if result is None:
raise HTTPException(status_code=404, detail="Stream not found or expired")
_media_id, _user_id, s3_key, total_size = result
suffix = s3_key.rsplit(".", 1)[-1].lower() if "." in s3_key else "mp4"
content_type = _CONTENT_TYPES.get(suffix, "video/mp4")
if range_header:
try:
range_val = range_header.replace("bytes=", "")
start_str, _, end_str = range_val.partition("-")
start = int(start_str)
end = int(end_str) if end_str else min(start + CHUNK_SIZE - 1, total_size - 1)
except ValueError as exc:
raise HTTPException(status_code=400, detail="Invalid Range header") from exc
start = max(0, start)
end = min(end, total_size - 1)
length = end - start + 1
async def ranged_stream():
async with async_s3_client() as s3:
resp = await s3.get_object(
Bucket=settings.s3_bucket_media,
Key=s3_key,
Range=f"bytes={start}-{end}",
)
async for chunk in resp["Body"].iter_chunks(CHUNK_SIZE):
yield chunk
return StreamingResponse(
ranged_stream(),
status_code=206,
media_type=content_type,
headers={
"Content-Range": f"bytes {start}-{end}/{total_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(length),
},
)
async def full_stream():
async with async_s3_client() as s3:
resp = await s3.get_object(Bucket=settings.s3_bucket_media, Key=s3_key)
async for chunk in resp["Body"].iter_chunks(CHUNK_SIZE):
yield chunk
return StreamingResponse(
full_stream(),
media_type=content_type,
headers={
"Accept-Ranges": "bytes",
"Content-Length": str(total_size),
},
)
+45
View File
@@ -0,0 +1,45 @@
import contextlib
import secrets
import time
from nats.aio.client import Client as NATSClient
from streamstack.core.nats import ensure_kv_bucket
BUCKET_NAME = "stream-tokens"
TOKEN_TTL_SECONDS = 3600
async def init_token_bucket(nc: NATSClient) -> None:
await ensure_kv_bucket(nc, BUCKET_NAME, ttl=TOKEN_TTL_SECONDS)
async def create_stream_token(
nc: NATSClient, media_id: str, user_id: str, s3_key: str, size_bytes: int
) -> str:
js = nc.jetstream()
kv = await js.key_value(BUCKET_NAME)
token = secrets.token_urlsafe(32)
payload = f"{media_id}|{user_id}|{int(time.time())}|{s3_key}|{size_bytes}"
await kv.put(token, payload.encode())
return token
async def resolve_stream_token(
nc: NATSClient, token: str
) -> tuple[str, str, str, int] | None:
js = nc.jetstream()
try:
kv = await js.key_value(BUCKET_NAME)
entry = await kv.get(token)
except Exception:
return None
parts = entry.value.decode().split("|")
if len(parts) != 5:
return None
media_id, user_id, issued_at, s3_key, size_bytes = parts
if time.time() - int(issued_at) > TOKEN_TTL_SECONDS:
with contextlib.suppress(Exception):
await kv.delete(token)
return None
return media_id, user_id, s3_key, int(size_bytes)
+37
View File
@@ -0,0 +1,37 @@
import asyncio
import logging
from contextlib import asynccontextmanager
import uvicorn
from fastapi import FastAPI
from streamstack.core import nats as nats_core
from streamstack.core import service_auth
from streamstack.core.config import settings
from streamstack.thumbnailer.worker import start_worker
logging.basicConfig(level=logging.INFO)
@asynccontextmanager
async def lifespan(app: FastAPI):
nc = await nats_core.connect()
await service_auth.init()
refresh_task = asyncio.create_task(service_auth.refresh_loop())
worker_task = asyncio.create_task(start_worker(nc))
yield
worker_task.cancel()
refresh_task.cancel()
await nats_core.disconnect()
app = FastAPI(title="StreamStack Thumbnailer", lifespan=lifespan)
@app.get("/v1/health")
async def health():
return {"status": "ok"}
def main():
uvicorn.run(app, host=settings.host, port=settings.port)
+103
View File
@@ -0,0 +1,103 @@
import asyncio
import json
import logging
from io import BytesIO
from pathlib import Path
import httpx
from nats.js.api import AckPolicy, ConsumerConfig, DeliverPolicy, StreamConfig
from streamstack.core import service_auth
from streamstack.core.config import settings
from streamstack.core.s3 import get_s3_client
from streamstack.streaming.pyav_utils import open_s3_container
log = logging.getLogger(__name__)
_STREAM = "CATALOGUE_EVENTS"
_SUBJECT = "catalogue.events.>"
_CONSUMER = "thumbnailer"
async def start_worker(nc) -> None:
js = nc.jetstream()
try:
await js.find_stream_name_by_subject("catalogue.events.media.published")
except Exception:
await js.add_stream(StreamConfig(name=_STREAM, subjects=[_SUBJECT]))
try:
await js.add_consumer(
_STREAM,
ConsumerConfig(
durable_name=_CONSUMER,
ack_policy=AckPolicy.EXPLICIT,
deliver_policy=DeliverPolicy.ALL,
),
)
except Exception:
pass # already exists
psub = await js.pull_subscribe(_SUBJECT, durable=_CONSUMER, stream=_STREAM)
log.info("Thumbnailer worker started, waiting for catalogue events")
while True:
try:
msgs = await psub.fetch(1, timeout=5)
for msg in msgs:
await _process(msg)
except Exception:
await asyncio.sleep(1)
async def _process(msg) -> None:
try:
data = json.loads(msg.data)
media_id = data["media_id"]
s3_key = data["s3_key"]
log.info("Generating thumbnail for media_id=%s s3_key=%s", media_id, s3_key)
loop = asyncio.get_event_loop()
thumb_key = await loop.run_in_executor(
None, _extract_thumbnail, settings.s3_bucket_media, s3_key
)
if thumb_key:
async with httpx.AsyncClient() as client:
resp = await client.patch(
f"{settings.catalogue_service_url}/v1/catalogue/{media_id}/thumbnail",
json={"thumbnail_s3_key": thumb_key},
headers={"Authorization": f"Bearer {service_auth.get_token()}"},
)
resp.raise_for_status()
log.info("Thumbnail stored at %s for media_id=%s", thumb_key, media_id)
else:
log.warning("Failed to extract thumbnail for s3_key=%s", s3_key)
await msg.ack()
except Exception as exc:
log.exception("Error processing thumbnail event: %s", exc)
await msg.nak()
def _extract_thumbnail(bucket: str, s3_key: str) -> str | None:
try:
container = open_s3_container(bucket, s3_key)
if container.duration:
container.seek(int(container.duration * 0.05))
for frame in container.decode(video=0):
img = frame.to_image()
buf = BytesIO()
img.save(buf, format="JPEG", quality=85)
thumb_key = f"thumbnails/{Path(s3_key).stem}.jpg"
get_s3_client().put_object(
Bucket=settings.s3_bucket_thumbnails,
Key=thumb_key,
Body=buf.getvalue(),
ContentType="image/jpeg",
)
return thumb_key
except Exception as exc:
log.exception("PyAV thumbnail extraction failed: %s", exc)
return None
View File
View File
+22
View File
@@ -0,0 +1,22 @@
from streamstack.auth.hasher import hash_password, verify_password
def test_hash_and_verify():
plain = "super-secret-password"
hashed = hash_password(plain)
assert hashed != plain
assert verify_password(plain, hashed)
def test_wrong_password_fails():
hashed = hash_password("correct")
assert not verify_password("wrong", hashed)
def test_different_hashes_for_same_password():
plain = "same-password"
hash1 = hash_password(plain)
hash2 = hash_password(plain)
assert hash1 != hash2
assert verify_password(plain, hash1)
assert verify_password(plain, hash2)
+127
View File
@@ -0,0 +1,127 @@
import subprocess
from unittest.mock import AsyncMock, patch
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from streamstack.auth.app import app
from streamstack.auth.models import Base
from streamstack.core.db import get_db
@pytest.fixture(scope="module")
def rsa_key_pair(tmp_path_factory):
tmp = tmp_path_factory.mktemp("keys")
priv = tmp / "private.pem"
pub = tmp / "public.pem"
subprocess.run(
["openssl", "genrsa", "-out", str(priv), "2048"], check=True, capture_output=True
)
subprocess.run(
["openssl", "rsa", "-in", str(priv), "-pubout", "-out", str(pub)],
check=True,
capture_output=True,
)
return str(priv), str(pub)
@pytest.fixture
async def db_session():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
yield session
await engine.dispose()
@pytest.fixture
def mock_nats():
nc = AsyncMock()
js = AsyncMock()
kv = AsyncMock()
nc.jetstream.return_value = js
js.key_value.return_value = kv
with patch("streamstack.auth.router.get_nats", return_value=nc):
yield nc
@pytest.fixture
async def test_client(db_session, mock_nats, rsa_key_pair):
priv, pub = rsa_key_pair
app.dependency_overrides[get_db] = lambda: db_session
with (
patch("streamstack.core.auth.settings") as ms,
patch("streamstack.auth.service.settings") as rs,
):
ms.jwt_private_key_path = priv
ms.jwt_public_key_path = pub
ms.jwt_algorithm = "RS256"
ms.jwt_expire_minutes = 30
ms.jwt_refresh_expire_days = 7
rs.jwt_public_key_path = pub
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_register(test_client):
resp = await test_client.post(
"/v1/auth/users/",
json={"email": "new@example.com", "password": "password123"},
)
assert resp.status_code == 201
body = resp.json()
assert body["email"] == "new@example.com"
assert "id" in body
@pytest.mark.asyncio
async def test_register_duplicate_email(test_client):
creds = {"email": "dup@example.com", "password": "pass"}
await test_client.post("/v1/auth/users/", json=creds)
resp = await test_client.post("/v1/auth/users/", json=creds)
assert resp.status_code == 409
@pytest.mark.asyncio
async def test_login_success(test_client):
await test_client.post(
"/v1/auth/users/", json={"email": "login@example.com", "password": "correct"}
)
resp = await test_client.post(
"/v1/auth/token",
data={"username": "login@example.com", "password": "correct"},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
assert resp.status_code == 200
body = resp.json()
assert "access_token" in body
assert "refresh_token" in body
assert body["token_type"] == "bearer"
@pytest.mark.asyncio
async def test_login_wrong_password(test_client):
await test_client.post(
"/v1/auth/users/", json={"email": "wp@example.com", "password": "correct"}
)
resp = await test_client.post(
"/v1/auth/token",
data={"username": "wp@example.com", "password": "wrong"},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_login_unknown_user(test_client):
resp = await test_client.post(
"/v1/auth/token",
data={"username": "nobody@example.com", "password": "pass"},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
assert resp.status_code == 401
View File
+104
View File
@@ -0,0 +1,104 @@
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from streamstack.catalogue.models import Base, MediaItem, Movie, TvSeries, YoutubeShow
@pytest.fixture
async def db_session():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
yield session
await engine.dispose()
@pytest.mark.asyncio
async def test_create_base_media_item(db_session: AsyncSession):
item = MediaItem(title="Generic Media", s3_key="media/generic.mp4")
db_session.add(item)
await db_session.commit()
await db_session.refresh(item)
assert item.id is not None
assert item.media_type == "media"
assert item.is_published is False
@pytest.mark.asyncio
async def test_create_movie(db_session: AsyncSession):
movie = Movie(
title="Inception",
s3_key="media/inception.mp4",
director="Christopher Nolan",
release_year=2010,
mpaa_rating="PG-13",
imdb_id="tt1375666",
)
db_session.add(movie)
await db_session.commit()
await db_session.refresh(movie)
assert movie.media_type == "movie"
assert movie.director == "Christopher Nolan"
assert movie.release_year == 2010
assert movie.mpaa_rating == "PG-13"
assert movie.imdb_id == "tt1375666"
@pytest.mark.asyncio
async def test_create_tv_series(db_session: AsyncSession):
episode = TvSeries(
title="Breaking Bad S01E01",
s3_key="media/bb-s01e01.mp4",
show_name="Breaking Bad",
season=1,
episode=1,
episode_title="Pilot",
network="AMC",
)
db_session.add(episode)
await db_session.commit()
await db_session.refresh(episode)
assert episode.media_type == "tv_series"
assert episode.show_name == "Breaking Bad"
assert episode.season == 1
assert episode.episode == 1
assert episode.episode_title == "Pilot"
assert episode.network == "AMC"
@pytest.mark.asyncio
async def test_create_youtube_show(db_session: AsyncSession):
video = YoutubeShow(
title="Python Tutorial",
s3_key="media/yt-python.mp4",
youtube_video_id="dQw4w9WgXcQ",
youtube_channel_id="UCxxxxxx",
channel_name="TechChannel",
)
db_session.add(video)
await db_session.commit()
await db_session.refresh(video)
assert video.media_type == "youtube_show"
assert video.youtube_video_id == "dQw4w9WgXcQ"
assert video.channel_name == "TechChannel"
@pytest.mark.asyncio
async def test_polymorphic_query_returns_correct_types(db_session: AsyncSession):
db_session.add(Movie(title="Movie A", s3_key="media/a.mp4", is_published=True))
db_session.add(TvSeries(title="Episode B", s3_key="media/b.mp4", is_published=True))
db_session.add(YoutubeShow(title="Video C", s3_key="media/c.mp4", is_published=True))
await db_session.commit()
from sqlalchemy import select
result = await db_session.execute(select(MediaItem))
items = result.scalars().all()
assert len(items) == 3
types = {i.media_type for i in items}
assert types == {"movie", "tv_series", "youtube_show"}
assert any(isinstance(i, Movie) for i in items)
assert any(isinstance(i, TvSeries) for i in items)
assert any(isinstance(i, YoutubeShow) for i in items)
+192
View File
@@ -0,0 +1,192 @@
from unittest.mock import AsyncMock, patch
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from streamstack.catalogue.app import app
from streamstack.catalogue.models import Base, MediaItem
from streamstack.core.db import get_db
from streamstack.core.middleware import UserClaims, verify_jwt
@pytest.fixture
async def db_session():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
yield session
await engine.dispose()
@pytest.fixture
def admin_user():
return UserClaims(sub="admin-uuid", email="admin@test.com", roles=["admin"], jti="test-jti")
@pytest.fixture
def viewer_user():
return UserClaims(sub="viewer-uuid", email="viewer@test.com", roles=["viewer"], jti="test-jti")
@pytest.fixture
def mock_nats():
nc = AsyncMock()
with patch("streamstack.catalogue.service.get_nats", return_value=nc):
yield nc
@pytest.fixture
async def test_client(db_session, mock_nats):
app.dependency_overrides[get_db] = lambda: db_session
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_health(test_client):
resp = await test_client.get("/v1/health")
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_list_empty(test_client):
resp = await test_client.get("/v1/catalogue/")
assert resp.status_code == 200
body = resp.json()
assert body["items"] == []
assert body["total"] == 0
@pytest.mark.asyncio
async def test_create_requires_admin(test_client, viewer_user, db_session):
app.dependency_overrides[get_db] = lambda: db_session
app.dependency_overrides[verify_jwt] = lambda: viewer_user
try:
resp = await test_client.post(
"/v1/catalogue/",
json={"media_type": "media", "title": "Test", "s3_key": "media/test.mp4"},
headers={"Authorization": "Bearer fake"},
)
finally:
app.dependency_overrides.pop(verify_jwt, None)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_create_movie(test_client, admin_user, db_session):
app.dependency_overrides[get_db] = lambda: db_session
app.dependency_overrides[verify_jwt] = lambda: admin_user
try:
resp = await test_client.post(
"/v1/catalogue/",
json={
"media_type": "movie",
"title": "Inception",
"s3_key": "media/inception.mp4",
"director": "Christopher Nolan",
"release_year": 2010,
"is_published": True,
},
headers={"Authorization": "Bearer fake"},
)
finally:
app.dependency_overrides.pop(verify_jwt, None)
assert resp.status_code == 201
body = resp.json()
assert body["media_type"] == "movie"
assert body["director"] == "Christopher Nolan"
assert body["release_year"] == 2010
@pytest.mark.asyncio
async def test_create_tv_series(test_client, admin_user, db_session):
app.dependency_overrides[get_db] = lambda: db_session
app.dependency_overrides[verify_jwt] = lambda: admin_user
try:
resp = await test_client.post(
"/v1/catalogue/",
json={
"media_type": "tv_series",
"title": "Breaking Bad S01E01",
"s3_key": "media/bb-s01e01.mp4",
"show_name": "Breaking Bad",
"season": 1,
"episode": 1,
"is_published": True,
},
headers={"Authorization": "Bearer fake"},
)
finally:
app.dependency_overrides.pop(verify_jwt, None)
assert resp.status_code == 201
body = resp.json()
assert body["media_type"] == "tv_series"
assert body["show_name"] == "Breaking Bad"
assert body["season"] == 1
@pytest.mark.asyncio
async def test_create_youtube_show(test_client, admin_user, db_session):
app.dependency_overrides[get_db] = lambda: db_session
app.dependency_overrides[verify_jwt] = lambda: admin_user
try:
resp = await test_client.post(
"/v1/catalogue/",
json={
"media_type": "youtube_show",
"title": "Python Tutorial",
"s3_key": "media/yt-python.mp4",
"youtube_video_id": "dQw4w9WgXcQ",
"channel_name": "TechChannel",
"is_published": True,
},
headers={"Authorization": "Bearer fake"},
)
finally:
app.dependency_overrides.pop(verify_jwt, None)
assert resp.status_code == 201
body = resp.json()
assert body["media_type"] == "youtube_show"
assert body["youtube_video_id"] == "dQw4w9WgXcQ"
@pytest.mark.asyncio
async def test_list_filtered_by_media_type(test_client, admin_user, db_session):
app.dependency_overrides[get_db] = lambda: db_session
app.dependency_overrides[verify_jwt] = lambda: admin_user
try:
await test_client.post(
"/v1/catalogue/",
json={"media_type": "movie", "title": "M1", "s3_key": "m/m1.mp4", "is_published": True},
headers={"Authorization": "Bearer fake"},
)
await test_client.post(
"/v1/catalogue/",
json={
"media_type": "tv_series",
"title": "T1",
"s3_key": "m/t1.mp4",
"is_published": True,
},
headers={"Authorization": "Bearer fake"},
)
finally:
app.dependency_overrides.pop(verify_jwt, None)
resp = await test_client.get("/v1/catalogue/?media_type=movie")
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 1
assert body["items"][0]["media_type"] == "movie"
@pytest.mark.asyncio
async def test_get_unpublished_returns_404(test_client, db_session):
item = MediaItem(title="Hidden", s3_key="media/hidden.mp4", is_published=False)
db_session.add(item)
await db_session.commit()
resp = await test_client.get(f"/v1/catalogue/{item.id}")
assert resp.status_code == 404
+50
View File
@@ -0,0 +1,50 @@
from collections.abc import AsyncGenerator
from unittest.mock import AsyncMock, MagicMock
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from streamstack.core.db import Base
@pytest.fixture(scope="function")
async def db_engine():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest.fixture(scope="function")
async def db_session(db_engine) -> AsyncGenerator[AsyncSession, None]:
session_factory = async_sessionmaker(db_engine, expire_on_commit=False)
async with session_factory() as session:
yield session
@pytest.fixture
def mock_nats():
nc = AsyncMock()
js = AsyncMock()
kv = AsyncMock()
nc.jetstream.return_value = js
js.key_value.return_value = kv
js.create_key_value.return_value = kv
js.find_key_value.return_value = kv
return nc
@pytest.fixture
def mock_s3_client():
client = MagicMock()
client.head_object.return_value = {
"ContentLength": 1024 * 1024,
"ContentType": "video/mp4",
}
return client
@pytest.fixture
def s3_content() -> bytes:
return b"fake video content " * 1000
View File
+60
View File
@@ -0,0 +1,60 @@
import subprocess
from unittest.mock import patch
import pytest
from streamstack.core.auth import create_access_token, create_refresh_token, decode_token
@pytest.fixture(scope="module")
def rsa_key_pair(tmp_path_factory):
tmp = tmp_path_factory.mktemp("keys")
priv = tmp / "private.pem"
pub = tmp / "public.pem"
subprocess.run(
["openssl", "genrsa", "-out", str(priv), "2048"],
check=True,
capture_output=True,
)
subprocess.run(
["openssl", "rsa", "-in", str(priv), "-pubout", "-out", str(pub)],
check=True,
capture_output=True,
)
return str(priv), str(pub)
def test_access_token_roundtrip(rsa_key_pair):
priv, pub = rsa_key_pair
with patch("streamstack.core.auth.settings") as mock_settings:
mock_settings.jwt_private_key_path = priv
mock_settings.jwt_public_key_path = pub
mock_settings.jwt_algorithm = "RS256"
mock_settings.jwt_expire_minutes = 30
token = create_access_token("user-123", "test@example.com", ["viewer"])
with patch("streamstack.core.auth.settings") as mock_settings:
mock_settings.jwt_public_key_path = pub
mock_settings.jwt_algorithm = "RS256"
payload = decode_token(token)
assert payload["sub"] == "user-123"
assert payload["email"] == "test@example.com"
assert payload["roles"] == ["viewer"]
assert "jti" in payload
assert "exp" in payload
def test_refresh_token_has_type(rsa_key_pair):
priv, pub = rsa_key_pair
with patch("streamstack.core.auth.settings") as mock_settings:
mock_settings.jwt_private_key_path = priv
mock_settings.jwt_public_key_path = pub
mock_settings.jwt_algorithm = "RS256"
mock_settings.jwt_expire_minutes = 30
mock_settings.jwt_refresh_expire_days = 7
token = create_refresh_token("user-123")
with patch("streamstack.core.auth.settings") as mock_settings:
mock_settings.jwt_public_key_path = pub
mock_settings.jwt_algorithm = "RS256"
payload = decode_token(token)
assert payload["type"] == "refresh"
assert payload["sub"] == "user-123"
+105
View File
@@ -0,0 +1,105 @@
import io
import boto3
import pytest
from moto import mock_aws
from streamstack.core.s3 import S3SeekableFile, open_s3_buffered
BUCKET = "test-bucket"
KEY = "test/video.mp4"
CONTENT = b"Hello World! This is seekable S3 content for testing purposes."
@pytest.fixture
def s3_object():
with mock_aws():
s3 = boto3.client("s3", region_name="us-east-1")
s3.create_bucket(Bucket=BUCKET)
s3.put_object(Bucket=BUCKET, Key=KEY, Body=CONTENT)
yield s3
def test_readable_and_seekable(s3_object):
f = S3SeekableFile(BUCKET, KEY, s3_client=s3_object)
assert f.readable() is True
assert f.seekable() is True
def test_size(s3_object):
f = S3SeekableFile(BUCKET, KEY, s3_client=s3_object)
assert f.size == len(CONTENT)
def test_initial_position(s3_object):
f = S3SeekableFile(BUCKET, KEY, s3_client=s3_object)
assert f.tell() == 0
def test_read_from_start(s3_object):
f = S3SeekableFile(BUCKET, KEY, s3_client=s3_object)
data = f.read(5)
assert data == CONTENT[:5]
assert f.tell() == 5
def test_seek_set(s3_object):
f = S3SeekableFile(BUCKET, KEY, s3_client=s3_object)
pos = f.seek(7)
assert pos == 7
assert f.tell() == 7
data = f.read(5)
assert data == CONTENT[7:12]
def test_seek_cur(s3_object):
f = S3SeekableFile(BUCKET, KEY, s3_client=s3_object)
f.read(5)
f.seek(2, io.SEEK_CUR)
assert f.tell() == 7
def test_seek_end(s3_object):
f = S3SeekableFile(BUCKET, KEY, s3_client=s3_object)
pos = f.seek(-5, io.SEEK_END)
assert pos == len(CONTENT) - 5
data = f.read()
assert data == CONTENT[-5:]
def test_seek_beyond_end_clamps(s3_object):
f = S3SeekableFile(BUCKET, KEY, s3_client=s3_object)
pos = f.seek(len(CONTENT) + 100)
assert pos == len(CONTENT)
def test_read_at_eof_returns_empty(s3_object):
f = S3SeekableFile(BUCKET, KEY, s3_client=s3_object)
f.seek(0, io.SEEK_END)
buf = bytearray(10)
n = f.readinto(buf)
assert n == 0
def test_full_read(s3_object):
f = S3SeekableFile(BUCKET, KEY, s3_client=s3_object)
data = f.read()
assert data == CONTENT
def test_open_s3_buffered(s3_object):
with mock_aws():
s3 = boto3.client("s3", region_name="us-east-1")
s3.create_bucket(Bucket=BUCKET)
s3.put_object(Bucket=BUCKET, Key=KEY, Body=CONTENT)
with patch_s3_client(s3):
buf = open_s3_buffered(BUCKET, KEY)
assert isinstance(buf, io.BufferedReader)
data = buf.read(5)
assert data == CONTENT[:5]
def patch_s3_client(client):
from unittest.mock import patch
return patch("streamstack.core.s3.get_s3_client", return_value=client)
+104
View File
@@ -0,0 +1,104 @@
import os
import time
import boto3
import httpx
import pytest
GATEWAY_URL = os.environ.get("GATEWAY_URL", "http://localhost:8080")
S3_ENDPOINT = os.environ.get("S3_ENDPOINT_URL", "http://localhost:9000")
S3_ACCESS_KEY = os.environ.get("S3_ACCESS_KEY", "minioadmin")
S3_SECRET_KEY = os.environ.get("S3_SECRET_KEY", "minioadmin")
S3_BUCKET = os.environ.get("S3_BUCKET_MEDIA", "media")
TESTDATA = os.path.join(os.path.dirname(__file__), "..", "..", "testdata")
TEST_VIDEOS = [
{
"filename": "Big_Buck_Bunny_1080_10s_30MB.mp4",
"s3_key": "media/big_buck_bunny.mp4",
"media_type": "movie",
"title": "Big Buck Bunny",
"director": "Sacha Goedegebure",
"release_year": 2008,
"is_published": True,
},
{
"filename": "Jellyfish_1080_10s_30MB.mkv",
"s3_key": "media/jellyfish.mkv",
"media_type": "movie",
"title": "Jellyfish",
"director": "Blender Foundation",
"release_year": 2015,
"is_published": True,
},
]
ADMIN_EMAIL = "e2e-admin@streamstack.test"
ADMIN_PASSWORD = "e2etestpassword"
@pytest.fixture(scope="session")
def s3():
return boto3.client(
"s3",
endpoint_url=S3_ENDPOINT,
aws_access_key_id=S3_ACCESS_KEY,
aws_secret_access_key=S3_SECRET_KEY,
region_name="us-east-1",
)
@pytest.fixture(scope="session")
def admin_token():
with httpx.Client(base_url=GATEWAY_URL) as client:
# Register; ignore 409 if already exists from a previous run
client.post(
"/api/v1/auth/users/",
json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD, "roles": ["admin", "viewer"]},
)
resp = client.post(
"/api/v1/auth/token",
data={"username": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
resp.raise_for_status()
return resp.json()["access_token"]
@pytest.fixture(scope="session")
def seeded_media(s3, admin_token):
items = []
with httpx.Client(base_url=GATEWAY_URL) as client:
for video in TEST_VIDEOS:
local_path = os.path.join(TESTDATA, video["filename"])
s3.upload_file(local_path, S3_BUCKET, video["s3_key"])
payload = {k: v for k, v in video.items() if k != "filename"}
resp = client.post(
"/api/v1/catalogue/",
json=payload,
headers={"Authorization": f"Bearer {admin_token}"},
)
resp.raise_for_status()
items.append(resp.json())
return items
def wait_for_service(url: str, timeout: int = 60) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
try:
r = httpx.get(url, timeout=5)
if r.status_code < 500:
return
except Exception:
pass
time.sleep(2)
raise RuntimeError(f"Service not ready: {url}")
def pytest_configure(config):
wait_for_service(f"{GATEWAY_URL}/api/v1/auth/health")
wait_for_service(f"{GATEWAY_URL}/api/v1/catalogue/health")
wait_for_service(f"{GATEWAY_URL}/api/v1/stream/health")
+101
View File
@@ -0,0 +1,101 @@
import os
import httpx
import pytest
GATEWAY = os.environ.get("GATEWAY_URL", "http://nginx:80")
@pytest.mark.e2e
def test_catalogue_lists_seeded_items(seeded_media):
with httpx.Client(base_url=GATEWAY) as client:
resp = client.get("/api/v1/catalogue/")
assert resp.status_code == 200
body = resp.json()
assert body["total"] >= 2
titles = {item["title"] for item in body["items"]}
assert "Big Buck Bunny" in titles
assert "Jellyfish" in titles
@pytest.mark.e2e
def test_catalogue_get_by_id(seeded_media):
with httpx.Client(base_url=GATEWAY) as client:
for item in seeded_media:
resp = client.get(f"/api/v1/catalogue/{item['id']}")
assert resp.status_code == 200
body = resp.json()
assert body["id"] == item["id"]
assert body["title"] == item["title"]
assert body["media_type"] == item["media_type"]
@pytest.mark.e2e
def test_catalogue_filter_by_media_type(seeded_media):
with httpx.Client(base_url=GATEWAY) as client:
resp = client.get("/api/v1/catalogue/?media_type=movie")
assert resp.status_code == 200
body = resp.json()
assert all(i["media_type"] == "movie" for i in body["items"])
assert body["total"] >= 2
@pytest.mark.e2e
def test_stream_token_issued(seeded_media, admin_token):
item = seeded_media[0]
with httpx.Client(base_url=GATEWAY) as client:
resp = client.post(
f"/api/v1/catalogue/{item['id']}/stream-token",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 200
body = resp.json()
assert "stream_url" in body
assert body["stream_url"].startswith("/api/v1/stream/")
@pytest.mark.e2e
def test_stream_full_response(seeded_media, admin_token):
item = seeded_media[0]
with httpx.Client(base_url=GATEWAY) as client:
token_resp = client.post(
f"/api/v1/catalogue/{item['id']}/stream-token",
headers={"Authorization": f"Bearer {admin_token}"},
)
token_resp.raise_for_status()
stream_url = token_resp.json()["stream_url"]
resp = client.get(stream_url, timeout=30)
assert resp.status_code == 200
assert "accept-ranges" in resp.headers
assert int(resp.headers["content-length"]) > 0
@pytest.mark.e2e
def test_stream_range_request(seeded_media, admin_token):
item = seeded_media[0]
with httpx.Client(base_url=GATEWAY) as client:
token_resp = client.post(
f"/api/v1/catalogue/{item['id']}/stream-token",
headers={"Authorization": f"Bearer {admin_token}"},
)
token_resp.raise_for_status()
stream_url = token_resp.json()["stream_url"]
resp = client.get(stream_url, headers={"Range": "bytes=0-65535"}, timeout=30)
assert resp.status_code == 206
assert "content-range" in resp.headers
assert resp.headers["content-length"] == "65536"
assert len(resp.content) == 65536
@pytest.mark.e2e
def test_stream_both_videos(seeded_media, admin_token):
with httpx.Client(base_url=GATEWAY) as client:
for item in seeded_media:
token_resp = client.post(
f"/api/v1/catalogue/{item['id']}/stream-token",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert token_resp.status_code == 200
stream_url = token_resp.json()["stream_url"]
resp = client.get(stream_url, headers={"Range": "bytes=0-1023"}, timeout=30)
assert resp.status_code == 206, f"Failed for {item['title']}"
+129
View File
@@ -0,0 +1,129 @@
import io
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from httpx import ASGITransport, AsyncClient
from streamstack.core.middleware import UserClaims, verify_jwt
from streamstack.ingest.app import app
ADMIN = UserClaims(sub="admin-uuid", email="admin@test.com", roles=["admin"], jti="jti")
VIEWER = UserClaims(sub="viewer-uuid", email="viewer@test.com", roles=["viewer"], jti="jti")
MOCK_CATALOGUE_ITEM = {
"id": str(uuid.uuid4()),
"media_type": "movie",
"title": "Test Movie",
"s3_key": "media/test.mp4",
"bucket": "media",
"content_type": "video/mp4",
"duration_seconds": 10.0,
"size_bytes": 1024,
"width": 1920,
"height": 1080,
"fps": 30.0,
"codec": "h264",
"thumbnail_s3_key": "thumbnails/test.jpg",
"is_published": True,
"tags": [],
"director": None,
"release_year": None,
"mpaa_rating": None,
"imdb_id": None,
"description": None,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
}
MOCK_META = {
"duration": 10.0,
"streams": [
{"type": "video", "codec": "h264", "width": 1920, "height": 1080, "fps": 30.0},
{"type": "audio", "codec": "aac", "sample_rate": 44100, "channels": 2},
],
}
@pytest.fixture
def mock_s3():
s3 = MagicMock()
s3.upload_fileobj.return_value = None
s3.head_object.return_value = {"ContentLength": 1024}
s3.put_object.return_value = None
with patch("streamstack.ingest.service.get_s3_client", return_value=s3):
yield s3
@pytest.fixture
def mock_metadata():
with patch("streamstack.ingest.service.extract_metadata", return_value=MOCK_META):
yield
@pytest.fixture
def mock_thumbnail():
with patch("streamstack.ingest.service._extract_thumbnail", return_value="thumbnails/test.jpg"):
yield
@pytest.fixture
def mock_catalogue():
http_response = MagicMock()
http_response.json.return_value = MOCK_CATALOGUE_ITEM
http_response.raise_for_status.return_value = None
async_client = MagicMock()
async_client.__aenter__ = AsyncMock(return_value=async_client)
async_client.__aexit__ = AsyncMock(return_value=False)
async_client.post = AsyncMock(return_value=http_response)
with patch("streamstack.ingest.service.httpx.AsyncClient", return_value=async_client):
yield async_client
async def _post_upload(ac, user):
app.dependency_overrides[verify_jwt] = lambda: user
try:
return await ac.post(
"/v1/ingest/upload",
files={"file": ("test.mp4", io.BytesIO(b"fake video"), "video/mp4")},
data={"media_type": "movie", "title": "Test Movie", "is_published": "true"},
headers={"Authorization": "Bearer fake"},
)
finally:
app.dependency_overrides.pop(verify_jwt, None)
@pytest.mark.asyncio
async def test_upload_success(mock_s3, mock_metadata, mock_thumbnail, mock_catalogue):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
resp = await _post_upload(ac, ADMIN)
assert resp.status_code == 201
body = resp.json()
assert body["media_type"] == "movie"
assert body["title"] == "Test Movie"
assert body["duration_seconds"] == 10.0
@pytest.mark.asyncio
async def test_upload_requires_admin(mock_s3, mock_metadata, mock_thumbnail, mock_catalogue):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
resp = await _post_upload(ac, VIEWER)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_upload_calls_catalogue_api(mock_s3, mock_metadata, mock_thumbnail, mock_catalogue):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
await _post_upload(ac, ADMIN)
mock_catalogue.post.assert_called_once()
call_kwargs = mock_catalogue.post.call_args
payload = call_kwargs.kwargs["json"]
assert payload["s3_key"].startswith("media/")
assert payload["s3_key"].endswith(".mp4")
assert payload["duration_seconds"] == 10.0
assert payload["width"] == 1920
assert payload["height"] == 1080
assert payload["codec"] == "h264"
assert payload["thumbnail_s3_key"] == "thumbnails/test.jpg"
View File
+105
View File
@@ -0,0 +1,105 @@
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from httpx import ASGITransport, AsyncClient
from streamstack.streaming.app import app
@pytest.fixture
def mock_nats_client():
nc = AsyncMock()
js = AsyncMock()
kv = AsyncMock()
nc.jetstream.return_value = js
js.key_value.return_value = kv
return nc, kv
@pytest.fixture
def mock_resolve_token_valid():
with patch(
"streamstack.streaming.service.resolve_stream_token",
new=AsyncMock(return_value=("media-uuid-abc", "user-1")),
):
yield
@pytest.fixture
def mock_resolve_token_invalid():
with patch(
"streamstack.streaming.service.resolve_stream_token",
new=AsyncMock(return_value=None),
):
yield
@pytest.fixture
def mock_s3_full():
s3 = MagicMock()
s3.head_object.return_value = {
"ContentLength": 1024,
"ContentType": "video/mp4",
}
body = MagicMock()
body.read.side_effect = [b"x" * 512, b"x" * 512, b""]
s3.get_object.return_value = {"Body": body}
with patch("streamstack.streaming.service.get_s3_client", return_value=s3):
yield s3
@pytest.fixture
def mock_nats_global(mock_nats_client):
nc, _ = mock_nats_client
with patch("streamstack.streaming.router.get_nats", return_value=nc):
yield nc
@pytest.mark.asyncio
async def test_health_endpoint():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
resp = await ac.get("/v1/health")
assert resp.status_code == 200
assert resp.json() == {"status": "ok"}
@pytest.mark.asyncio
async def test_stream_not_found(mock_resolve_token_invalid, mock_nats_global):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
resp = await ac.get("/v1/stream/invalid-token")
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_stream_full_response(mock_resolve_token_valid, mock_nats_global, mock_s3_full):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
resp = await ac.get("/v1/stream/valid-token")
assert resp.status_code == 200
assert resp.headers["accept-ranges"] == "bytes"
assert resp.headers["content-length"] == "1024"
@pytest.mark.asyncio
async def test_stream_range_request(mock_resolve_token_valid, mock_nats_global):
s3 = MagicMock()
s3.head_object.return_value = {"ContentLength": 10000, "ContentType": "video/mp4"}
body = MagicMock()
body.read.side_effect = [b"y" * 500, b""]
s3.get_object.return_value = {"Body": body}
with patch("streamstack.streaming.service.get_s3_client", return_value=s3):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
resp = await ac.get("/v1/stream/valid-token", headers={"Range": "bytes=0-499"})
assert resp.status_code == 206
assert "content-range" in resp.headers
assert resp.headers["content-range"] == "bytes 0-499/10000"
assert resp.headers["content-length"] == "500"
@pytest.mark.asyncio
async def test_stream_invalid_range_header(mock_resolve_token_valid, mock_nats_global):
s3 = MagicMock()
s3.head_object.return_value = {"ContentLength": 10000, "ContentType": "video/mp4"}
with patch("streamstack.streaming.service.get_s3_client", return_value=s3):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
resp = await ac.get("/v1/stream/valid-token", headers={"Range": "bytes=abc-def"})
assert resp.status_code == 400
+72
View File
@@ -0,0 +1,72 @@
import time
from unittest.mock import AsyncMock, MagicMock
import pytest
from streamstack.streaming.tokens import (
TOKEN_TTL_SECONDS,
create_stream_token,
resolve_stream_token,
)
@pytest.fixture
def mock_nc():
nc = AsyncMock()
js = AsyncMock()
kv = AsyncMock()
# jetstream() is a synchronous call in nats.py
nc.jetstream = MagicMock(return_value=js)
js.key_value = AsyncMock(return_value=kv)
return nc, kv
@pytest.mark.asyncio
async def test_create_returns_token(mock_nc):
nc, kv = mock_nc
token = await create_stream_token(nc, "media-uuid", "user-123")
assert len(token) > 20
kv.put.assert_called_once()
key_used = kv.put.call_args[0][0]
assert key_used == token
@pytest.mark.asyncio
async def test_resolve_valid_token(mock_nc):
nc, kv = mock_nc
now = int(time.time())
entry = MagicMock()
entry.value = f"media-uuid|user-123|{now}".encode()
kv.get.return_value = entry
result = await resolve_stream_token(nc, "some-token")
assert result == ("media-uuid", "user-123")
@pytest.mark.asyncio
async def test_resolve_expired_token(mock_nc):
nc, kv = mock_nc
expired_time = int(time.time()) - TOKEN_TTL_SECONDS - 1
entry = MagicMock()
entry.value = f"media-uuid|user-123|{expired_time}".encode()
kv.get.return_value = entry
result = await resolve_stream_token(nc, "some-token")
assert result is None
kv.delete.assert_called_once_with("some-token")
@pytest.mark.asyncio
async def test_resolve_missing_token(mock_nc):
nc, kv = mock_nc
kv.get.side_effect = Exception("not found")
result = await resolve_stream_token(nc, "nonexistent")
assert result is None
@pytest.mark.asyncio
async def test_resolve_malformed_token(mock_nc):
nc, kv = mock_nc
entry = MagicMock()
entry.value = b"bad-data"
kv.get.return_value = entry
result = await resolve_stream_token(nc, "some-token")
assert result is None
Generated
+2128
View File
File diff suppressed because it is too large Load Diff