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