How to run Streamarr, configure it, verify it end-to-end before touching Jellyfin,
and then bolt Jellyfin on. Pairs with architecture.md (what the
pieces are) and api.md (what they expose).
Prerequisite reality check (DECISIONS.md open items): Streamarr needs real Usenet provider credentials and at least one Newznab indexer API key to resolve and stream live content. Until you supply them, the test suite and the latency harness run against an in-repo mock NNTP server + canned indexer/TMDB fixtures — enough to prove the plumbing, not to stream real media. Put real credentials in
appsettings.Local.json(git-ignored) or the Management UI. Seem1-latency.md.
docker-compose.dev.yml brings up Jellyfin + the Core
Server, with an optional Vite web profile.
# 1. Configure unique credentials. Compose fails while either value is empty.
cp .env.example .env
${EDITOR:-vi} .env
# 2. Build the Jellyfin plugin (its DLL is bind-mounted into Jellyfin)
(cd plugin && dotnet build Streamarr.Plugin/Streamarr.Plugin.csproj -c Release)
# 3. Bring up Jellyfin + Core Server
docker compose -f docker-compose.dev.yml up --build
# 4. (optional) also run the Management UI on Vite
docker compose -f docker-compose.dev.yml --profile web up --build- Core Server →
http://localhost:8080(/api/v1,/openapi/v1.json, and the built SPA at/). - Jellyfin →
http://localhost:8096(waits on the Core'sGET /api/v1/health?deep=falsehealthcheck before starting). - Vite web (profile
web) →http://localhost:5173, proxying/api+/openapito the Core.
The compose file reads the bootstrap username, password and machine key from .env,
fails fast when either secret is empty, and binds every published port to loopback.
Set JELLYFIN_UID / JELLYFIN_GID to the owner of the plugin build output and the
Jellyfin volumes (the example defaults to 1000:1000). Both application containers
run without Linux capabilities, with a read-only root filesystem and
no-new-privileges; only their declared volumes and bounded tmpfs mounts are writable.
The Jellyfin container mounts the plugin's build output read-write into
/config/plugins/Streamarr (Jellyfin rewrites meta.json on load; a read-only mount
fails).
For production the Core Server serves the Management UI itself from wwwroot/ as
static files — single container, single origin (BRIEF §4). The multi-stage
server/Dockerfile does all of it:
- builds the React SPA (
node), - publishes the ASP.NET Core app (
dotnet sdk), copyingweb/dist/*intowwwroot/, - ships a slim
aspnetruntime with ffmpeg (suppliesffprobefor/resolve) and curl (backs the healthcheck).
# build context is the repo root
docker build -f server/Dockerfile -t streamarr .
docker run --init --read-only --cap-drop ALL --security-opt no-new-privileges \
--tmpfs /tmp:rw,noexec,nosuid,nodev,size=64m \
-p 127.0.0.1:8080:8080 \
-e STREAMARR_ADMIN_PASSWORD='choose-a-strong-one' \
-e Streamarr__ApiKey='replace-with-at-least-32-random-characters' \
-v streamarr-data:/app/data -v streamarr-keys:/app/keys \
streamarrThe image stores SQLite at /app/data/streamarr.db, persists Data Protection keys at
/app/keys, and runs as the unprivileged app user. Back up both volumes together.
The application port is intentionally HTTP-only inside the container. A production
installation must terminate HTTPS at a trusted reverse proxy/VPN ingress and proxy
to this loopback/private port. Do not change the example to -p 8080:8080 on an
internet-facing host. Configure the proxy to redact /api/v1/stream/* paths and all
query strings from access logs, and limit request/body sizes at the edge as a second
layer of defense.
For example, a Caddy instance on the same host can terminate TLS automatically:
streamarr.example.com {
encode zstd gzip
reverse_proxy 127.0.0.1:8080
}Keep proxy access logging disabled for stream paths unless its configuration can reliably redact the capability token.
Loopback reverse proxies are trusted automatically. If the proxy reaches Kestrel from
another container or host, add only its exact source address through
Streamarr__TrustedProxies__0 (and increment the final index for additional proxies).
Do not configure an entire client network: only listed proxy addresses may supply
X-Forwarded-For and X-Forwarded-Proto, and Streamarr accepts one forwarding hop.
When wwwroot/index.html exists the server enables static-file serving + an SPA
fallback (client routes like /settings resolve to index.html), while /api and
/openapi keep their own behavior. In development you instead run Vite (npm run dev)
proxying to Kestrel — both paths are supported.
Cookie-authenticated state changes require a same-origin request. When the Management UI
is reached through a TLS-terminating tunnel or a forwarded per-app URL (e.g. Codecraft dev
actions), the browser's Origin can never match the origin Kestrel reconstructs locally,
so those POST/PUT/DELETEs fail with 403 csrf_rejected. List each browser-visible
origin (scheme://host[:port], no path) through Streamarr__TrustedOrigins__0 (increment
the index for more). Leave it empty for a plain single-origin deployment. The dev stack and
native server action already wire this from the CODECRAFT_URL_* public URLs.
Config resolves from four overlapping places (later wins for a given key):
appsettings.json— checked-in defaults, under theStreamarrsection.appsettings.Local.json— git-ignored; put real provider credentials, real indexer keys, and your TMDB key here for local runs.- Environment variables —
Streamarr__ApiKey,Streamarr__Admin__Password,STREAMARR_ADMIN_PASSWORD, etc. (ASP.NET's__maps to config nesting).INDEXER_PROXYis an explicit alias forStreamarr:IndexerProxyand takes precedence over the nested setting. - The Management UI / config API — the SQLite-backed source of truth for indexers, providers, profiles, general config, and API keys. On startup the persisted config is overlaid onto the bound options, so once you have configured things in the UI, that is what runs. Provider pool changes are applied atomically to subsequent NNTP commands; an already-borrowed connection may finish before its retired pool is disposed.
First-run bootstrap: with an empty users table an admin is seeded from
STREAMARR_ADMIN_PASSWORD / Streamarr:Admin:Password. Outside Development a
configured 12–1024 character password without control characters is required and a
missing value fails startup. Only Development may generate and log a random fallback
once. Machine clients authenticate with the optional static Streamarr:ApiKey or a key
minted via POST /api/v1/config/apikeys; when enabled, the static key must be 32–4096
characters without whitespace or control characters. Secrets are encrypted at rest
(ASP.NET Data Protection key ring under
Streamarr:DataProtectionKeysPath) and never returned in plaintext by the API.
Set INDEXER_PROXY to the origin of an HTTP proxy reachable from the Streamarr
container. For Gluetun's built-in proxy on the same Compose network:
INDEXER_PROXY=http://gluetun:8888Enable HTTPPROXY=on on the Gluetun service. Streamarr then explicitly sends Newznab
searches, capability tests, and NZB-file retrieval through that proxy. TMDB requests
and NNTP article/media connections remain direct. There is no direct fallback when a
configured proxy is unavailable, and generic HTTP_PROXY / HTTPS_PROXY variables do
not change this routing policy. The proxy value must be an http:// origin without
credentials, a path, query, or fragment.
Open the Management UI and configure, in this order:
- Usenet provider — host, port, SSL, credentials, max connections. Hit Test
→ it connects +
AUTHINFOand reports achievable connections. Add a second, lower-priority provider (a block/backup account) if you have one — failover is automatic (seearchitecture.md§5.2). - Indexers — Newznab base URL + API key per indexer. Hit Test → a
t=capsroundtrip showing caps + latency. Enable/disable and order by priority. - TMDB credential — enter either the short v3 API key or the API Read Access Token
(JWT) under Settings → General, or as
TMDB_API_KEYin the Compose.envfile. It is required for public semantic discovery, canonical metadata, artwork, and Jellyfin injection. Without it, raw/rejected indexer hits remain available only in the Release diagnostics tab and/debug/search. - Quality profile — start from the built-in Standard default and tune it later
under Search → Release diagnostics. See
ranker-tuning.md.
This is the step that proves the Core is sound and the abstraction has not leaked (BRIEF §3.1 rule 4, §11):
- Open Search, use Semantic discovery to verify available works, then open Release diagnostics to inspect parsed fields, per-rule score breakdown, and any rejection reasons.
- Resolve a release: see the health-check outcome and the pre-probed media info.
- Hit Playback preview: the resolved stream plays in a plain HTML5
<video>element — with Jellyfin not running at all — instrumented for time-to-first-frame and seek latency.
If preview-play works, the Core is doing the whole job (search → rank → resolve → health-check → stream) with no Jellyfin in the loop. If it breaks, treat it as a build failure, not a UI bug.
- Build:
(cd plugin && dotnet build -c Release). The compose file bind-mounts the output DLL into Jellyfin's plugin dir; otherwise dropStreamarr.Plugin.dll(+meta.json) into/config/plugins/Streamarr(read-write). - In Jellyfin → Dashboard → Plugins, confirm Streamarr is listed and Active.
- Open its (deliberately minimal) config page. Set Core Server URL to the private
control URL Jellyfin can reach (
http://streamarr:8080in compose), set Public stream URL to an HTTPS reverse-proxy or private-LAN base URL reachable by every playback device, and enter the machine API key. The public URL is required when the control URL uses a container-only hostname; leave it blank only when the control URL itself is client-reachable. Hit Test connection. This calls anonymous shallowGET /api/v1/health?deep=falseand then authenticatedGET /api/v1/caps; both must succeed, so the test validates the control URL and key rather than liveness alone. - Turn on Enable search interception.
Usenet results now appear alongside your local library and play through Jellyfin's
transcoding pipeline. Movie results are availability-filtered immediately. TV results
appear as series folders (at most three TMDB matches); seasons load when the series is
opened, and one season-wide indexer search populates all canonical episodes when that
season is opened. Synthetic items live under a private, hidden implementation folder
and are returned only through eligible intercepted searches; they do not appear in
normal library browsing. The plugin is pinned to Jellyfin 10.11.11 and the search
interception is version-fragile — see
jellyfin-compatibility.md and the manual acceptance
checklist in m5-acceptance.md.
Every key under the Streamarr section, from
Options/StreamarrOptions.cs.
Bind via appsettings*.json ("Streamarr": { … }) or env vars (Streamarr__Key).
| Key | Default | Meaning |
|---|---|---|
ApiKey |
"" |
Static bootstrap machine API key for bearer auth. Empty disables it; when enabled it must be 32–4096 characters without whitespace/control characters. Keys minted via the config API still work. |
ConnectionString |
"" |
SQLite connection string. Empty → streamarr.db next to the app. |
AdminSessionTtlSeconds |
3600 |
Lifetime of the admin session cookie/JWT from POST /auth/login. |
LoginAttemptsPerMinute |
5 |
Fixed-window login-attempt limit per client IP. |
DataProtectionKeysPath |
"" |
Directory the secret-encryption key ring persists to. Empty → a keys folder next to the app. |
NzbCachePath |
"" |
Persistent NZB cache directory. Empty → cache/nzb below the Core content root. Container images default to /app/data/nzb. |
NzbCacheSizeMb |
1024 |
Maximum total size of cached NZB source documents. Least-recently-used entries are pruned first. |
NzbCacheMaxEntries |
2000 |
Maximum cached release count. |
ConnectionBudget |
20 |
Global NNTP connection budget shared across all sessions (BODY/ARTICLE outrank STAT/HEAD). |
SessionTtlSeconds |
86400 |
Hard maximum age of an ephemeral file from creation. Access updates LRU order but never extends this deadline. Existing installs on the former 3600 default migrate to 24 hours. |
EphemeralCacheSizeMb |
102400 |
Logical decoded-file budget for ephemeral capabilities. A new admission evicts whole files by oldest last access until it fits; one file larger than the budget may stand alone. |
SessionSweepIntervalSeconds |
30 |
How often the session manager sweeps for expired sessions. |
MaxSessions |
64 |
Safety cap on retained capability sessions; at the limit the least-recently-accessed entry is evicted so a new file is not rejected. |
MaxConcurrentStreams |
128 |
Hard cap on concurrently open HTTP stream bodies. |
MaxConcurrentResolves |
4 |
Hard cap on full NZB/health/materialization resolve pipelines in flight. |
MaxConcurrentSearches |
4 |
Hard cap on concurrent indexer fan-out searches. |
IndexerProxy |
"" |
HTTP proxy used only for Newznab searches/caps and NZB retrieval. INDEXER_PROXY is the preferred deployment alias and takes precedence. Empty means explicitly direct. |
MaxFallbackHops |
3 |
(M7) Max automatic fallback hops when a release resolves dead, so a fully-dead work fails fast. |
HealthCacheTtlSeconds |
1800 |
(M7) How long a dead classification is remembered and fed back into ranking + fallback selection. 0 disables the health cache. |
ArticleReadAheadCount |
3 |
Maximum adjacent articles downloaded concurrently and delivered in order. |
ArticleDownloadRetryCount |
2 |
Whole-article retries after an interrupted transfer or yEnc validation failure. |
SegmentCacheSizeMb |
512 |
Process-wide decoded-article LRU size. 0 disables caching and in-flight request deduplication. |
FfprobePath |
ffprobe |
Path to the ffprobe binary used to pre-probe the stream at resolve. |
FfprobeTimeoutSeconds |
60 |
Timeout for the server-side ffprobe run. |
MaxConcurrentFfprobe |
2 |
Hard cap on concurrent ffprobe child processes. |
MaxNzbBytes |
67108864 |
Maximum compressed NZB response size accepted from an indexer. |
MaxNzbFiles |
10000 |
Maximum file entries accepted from one NZB. |
MaxNzbSegments |
1000000 |
Maximum total segment entries accepted from one NZB. |
MaxMediaBytes |
17592186044416 |
Maximum decoded size of one materialized media file (16 TiB). |
AllowLocalNzbFiles |
false |
Test-only escape hatch for local NZB paths; keep disabled in production. |
SearchCacheMaxEntries |
1000 |
Hard bound for the in-memory search cache. |
HealthCacheMaxEntries |
10000 |
Hard bound for cached release-health classifications. |
ReleaseStoreMaxEntries |
10000 |
Hard bound for the in-memory release lookup store. |
TmdbCacheMaxEntries |
5000 |
Hard bound for cached TMDB metadata. |
MaxWatchEvents |
10000 |
Maximum retained playback-event rows; oldest rows are pruned on write. |
DeepHealthCacheSeconds |
30 |
Shared cache lifetime for admin-only dependency probes. |
Admin |
— | First-run admin bootstrap (below). |
Providers[] |
[] |
Priority-ordered Usenet providers (below). |
Indexers[] |
[] |
Newznab indexers seeding the config store (below). |
Search |
— | Indexer fan-out tunables (below). |
Tmdb |
— | TMDB matcher config (below). |
HealthCheck |
— | NNTP STAT health-check knobs (below). |
| Key | Default | Meaning |
|---|---|---|
Admin.Username |
admin |
Bootstrap admin username (only used when the users table is empty). |
Admin.Password |
"" |
Bootstrap password, also settable via STREAMARR_ADMIN_PASSWORD. Must be 12–1024 characters without control characters. Required outside Development; only Development generates/logs a random fallback once. |
| Key | Default | Meaning |
|---|---|---|
Name |
"" |
Display name (surfaced in /metrics, /caps). |
Host |
"" |
NNTP hostname. |
Port |
563 |
NNTP port (563 = NNTPS). |
UseSsl |
true |
TLS to the provider. |
Username / Password |
"" |
Credentials (AUTHINFO). Encrypted at rest via the config store. |
MaxConnections |
10 |
Per-provider connection cap (subordinate to the global ConnectionBudget). |
Priority |
0 |
Lower = tried first. A block/backup account gets a higher number. |
Type |
Pooled |
Provider type (Pooled / Disabled). |
| Key | Default | Meaning |
|---|---|---|
Id |
"" |
Stable id; falls back to Name when omitted. |
Name |
"" |
Display name. |
BaseUrl |
"" |
Newznab API base URL. |
ApiKey |
"" |
Indexer API key. Never leaves the server. |
Categories[] |
[] |
Newznab category ids to search. |
Enabled |
true |
Include in the fan-out. |
Priority |
0 |
Ordering / tie-break among indexers. |
| Key | Default | Meaning |
|---|---|---|
SearchCacheTtlSeconds |
60 |
Search-result cache lifetime (keyed by normalized query). |
PerIndexerTimeoutSeconds |
30 |
Per-indexer request timeout; a slow indexer is dropped, not awaited. |
PerIndexerRateLimitMilliseconds |
1000 |
Minimum gap between consecutive requests to the same indexer. |
DefaultLimit |
100 |
Result cap sent to each indexer when the query sets none. |
MaxResponseBytes |
16777216 |
Maximum XML response body accepted from one indexer. |
MaxIndexersPerSearch |
32 |
Maximum enabled indexers included in one fan-out. |
MaxConcurrentIndexerRequests |
8 |
Process-wide maximum in-flight Newznab requests across all searches. |
| Key | Default | Meaning |
|---|---|---|
ApiKey |
"" |
TMDB v3 API key or API Read Access Token (JWT). Empty → public semantic search returns no works; raw indexer results remain available through /debug/search. |
BaseUrl |
https://api.themoviedb.org/3 |
TMDB API base. |
ImageBaseUrl |
https://image.tmdb.org/t/p |
Image CDN base. |
PosterSize / BackdropSize |
w780 / w1280 |
Requested image sizes. |
Language |
null |
Optional ISO 639-1 response language (e.g. en-US). |
CacheTtlHours |
24 |
Metadata cache lifetime (cached aggressively). |
MaxResponseBytes |
4194304 |
Maximum decompressed JSON response body accepted from TMDB. |
RequestTimeoutSeconds |
20 |
Hard lifetime for one shared upstream lookup, including admission wait. |
MaxConcurrentRequests |
4 |
Process-wide maximum TMDB requests in flight. |
| Key | Default | Meaning |
|---|---|---|
SampleCount |
24 |
Max segments STAT'ed per release (evenly spread, incl. first/last). |
Concurrency |
8 |
Concurrent STAT probes. |
DeadMissingRatio |
0.5 |
Missing-sample ratio at/above which a release is dead (below → degraded). |
Open Settings → Notifications to connect Streamarr to the Pushover Message API. Create a Pushover application, enter its application token and a user or delivery-group key, save, then use Send test. Both credentials are encrypted at rest and are never returned by the API.
Notification routing is opt-in and can be tuned independently:
- routine events: server startup, playback start/progress/stop, and successful resolves;
- failures: resolve failures and otherwise-unhandled server errors;
- availability: indexer/provider outages after a configurable number of failed probes, reminders while an outage remains active, and a single recovery notification;
- content: user name, playback device, and internal release ID can each be excluded;
- delivery: routine, error, outage, and recovery priorities are independent. Emergency priority uses the configured Pushover retry and expiry values.
Playback progress and repeated errors have separate cooldowns. Pushover delivery uses a bounded background queue, so a slow or unavailable Pushover service does not delay search, resolve, playback, or event ingestion. Outage monitoring starts only after notifications are enabled.
# Core
cd server
dotnet run --project src/Streamarr.Server # /openapi/v1.json, Swagger UI at /swagger (dev)
dotnet test # parser corpus, ranker, auth, streaming, budget, load
scripts/freeze-openapi.sh # re-freeze the OpenAPI contract (CI fails on drift)
# Management UI
cd web
npm install
npm run generate:api # ../server/openapi/v1.json → src/api/schema.d.ts (CI fails on drift)
npm run dev # Vite on :5173, proxying /api + /openapi (override target: STREAMARR_SERVER_ORIGIN)
npm test # Vitest + Testing Library
npm run build # type-check + production SPA build → web/dist
# Latency harness (mock baseline; real once credentials exist — see m1-latency.md)
dotnet run --project server/tools/latency -- --mode mock --iterations 12 --markdownarchitecture.md— the components and the request lifecycle.api.md— the endpoints the config you set here drives.ranker-tuning.md— tuning quality profiles in the playground.jellyfin-compatibility.md— the pinned Jellyfin version and re-test procedure.m1-latency.md— cold-start/seek measurement and the mock-vs-real situation.