Skip to content

REST API

DataHearth edited this page Aug 18, 2026 · 35 revisions

REST API

Streamline's API is the same one its own web UI uses — there's no privileged internal surface. Anything the SPA can do, you can do.


Interactive docs

URL What
/api/docs Scalar UI — browse and try endpoints
/api/v1/openapi.yaml The raw OpenAPI 3.0.4 spec

The spec is the source of truth: the Go server types are generated from it with oapi-codegen, so it can't drift from the implementation.

Base URL for everything below: /api/v1.


Authentication

Two credentials work on /api/v1/*:

# API key — for scripts and long-lived integrations
curl -H "X-API-Key: $KEY" https://streamline.example.com/api/v1/movies

# Bearer JWT — for a session obtained by logging in
curl -H "Authorization: Bearer $JWT" https://streamline.example.com/api/v1/movies

Cookies are ignored on /api/v1 except for same-origin browser requests carrying Sec-Fetch-Site: same-origin — that's how the SPA authenticates without holding a second credential. Anything outside a browser needs a key or a token.

Failures return 401 with a JSON body. No redirects on the API surface.

The two credentials are equal on media and settings endpoints, but API keys are read-only on the identity surface: any non-GET request under /auth/me, /auth/password, /auth/invites, /auth/jwt, or /users returns 403 with a key — those actions need a session (Bearer JWT or the SPA cookie). That's why the key-creation example below authenticates with a JWT.

Getting an API key

Account settings → API keys, or:

curl -X POST -H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' \
  -d '{"name":"my-script"}' \
  https://streamline.example.com/api/v1/auth/me/api-keys

The raw key is returned once. A key inherits its owner's permissions — an admin's key is an admin key, so create read-only integrations under a member account.

Getting a JWT

curl -c cookies.txt -X POST -H 'Content-Type: application/json' \
  -d '{"email":"you@example.com","password":"..."}' \
  https://streamline.example.com/auth/login

Note the path: /auth/login, not /api/v1/auth/login. It returns 204 and sets streamline_session; the cookie's value is the JWT, so you can lift it out and use it as a Bearer token.

For anything non-interactive, use an API key instead.


Conventions

Pagination. Collection endpoints take ?page= (default 1) and ?limit= (default 20) and return an envelope:

{ "items": [ ... ], "total": 137, "page": 1, "limit": 20 }

Activity feeds use cursor pagination instead (?cursor=, ?limit=), since they're append-only and time-ordered.

Name-keyed resources. Config-backed resources are addressed by name, not numeric ID:

/indexers/{name}          /download-clients/{name}
/media-servers/{name}     /quality-profiles/{name}
/schedules/{name}

Everything database-backed (movies, series, requests, users, imports) uses numeric IDs.

Update verbs are not uniform. Media servers use PATCH; indexers, download clients and quality profiles use PUT. This is a genuine inconsistency in the API, not a documentation error — check the spec if in doubt.

Secrets are never returned. Read views expose booleans — api_key_set, password_set, client_secret_set — instead of values. On update, sending a blank secret preserves the existing one rather than clearing it, so you can round-trip a config object without leaking or destroying credentials.

Errors are {"message": "..."} with a conventional status: 400 bad request, 401 unauthenticated, 403 forbidden (usually not an admin), 404, 409 conflict (already exists), 422 unprocessable, 500.


Endpoint map

107 paths. Grouped, with admin-only marked 🔒.

Movies

Method Path
GET POST /movies
GET /movies/counts
GET PATCH DELETE /movies/{id}
POST /movies/{id}/search · /search-now · /grab · /refresh-metadata · /rename · /play-on
GET /movies/{id}/recommendations
DELETE /movies/{id}/files/{fileId}
GET /search/movie · /search/movie/{tmdb_id} — TMDB lookup

Series

Method Path
GET POST /series
GET /series/counts · /series/lookup · /series/lookup/{tvdb_id}
POST /series/specials/apply
GET PATCH DELETE /series/{id}
GET /series/{id}/browse
POST /series/{id}/search · /grab · /refresh-metadata · /rename · /play-on
PATCH /series/{id}/seasons/{number}
POST /series/{id}/seasons/{number}/search · /grab
GET PATCH /series/{id}/episodes/{episodeId}
POST /series/{id}/episodes/{episodeId}/search · /grab
DELETE /series/{id}/episodes/{episodeId}/file

Activity

Method Path
GET /activity — event feed
GET /activity/queue · /activity/history
DELETE /activity/queue/{id} · /activity/history/{id}
POST /activity/queue/{id}/pause · /resume · /activity/history/clear-completed
GET /activity/pending 🔒
POST /activity/pending/{id}/import · /replace · /ignore 🔒

Requests

Method Path Who
GET POST /requests Any (scoped for request_only)
GET /requests/counts · /requests/{id}/metadata Any
POST /requests/{id}/approve admin, member
POST /requests/{id}/deny · /reopen admin

Config-backed resources 🔒

Method Path
GET POST /indexers · /download-clients · /media-servers · /quality-profiles
GET DELETE /{resource}/{name}
PUT /indexers/{name} · /download-clients/{name} · /quality-profiles/{name}
PATCH /media-servers/{name}
POST /{resource}/test — test an unsaved config
POST /{resource}/{name}/test — test a saved one
GET /media-servers/discover — list libraries/sections

Torrents 🔒 (built-in client)

Method Path
GET /torrents · /torrents/{hash}
POST /torrents/{hash}/pause · /resume
PATCH /torrents/{hash}/files/{index} — toggle a file

Library 🔒

Method Path
GET POST /library/imports
GET DELETE /library/imports/{id}
POST /library/imports/{id}/cancel · /commit
GET /library/imports/{id}/files · /shows
PATCH /library/imports/{id}/files/{fileId} · /shows/{showId}
GET POST /library/path-migration
GET /library/path-migration/roots
POST /library/path-migration/preview

Auth and users

Method Path Who
GET PATCH /auth/me Any
PUT /auth/password Any
GET POST /auth/me/api-keys · /auth/me/sessions Any
DELETE /auth/me/api-keys/{id} · /auth/me/sessions/{id} Any
POST /auth/jwt/rotate 🔒
GET POST /auth/invites 🔒
DELETE /auth/invites/{id} 🔒
GET POST /users 🔒
GET PATCH DELETE /users/{uid} 🔒
POST /users/{uid}/password-reset · /unlock 🔒
DELETE /users/{uid}/api-keys/{kid} · /sessions/{sid} 🔒

Config, schedules, system 🔒

Method Path
GET PATCH /config/auth · /config/library · /config/ffmpeg
GET POST /config/oidc
GET PATCH DELETE /config/oidc/{name}
GET /schedules · /schedules/{name}
PATCH /schedules/{name}
POST /schedules/{name}/pause · /resume · /run
GET /system/info

Calendar

Method Path
GET /calendar/upcoming?from=&to= — movie digital releases and episode air dates

Outside /api/v1

Path Notes
GET /health Unauthenticated probe. Bare JSON, deliberately not in the spec
POST /auth/login · /auth/register · /auth/logout Cookie-based, 204 on success
GET /auth/config · /auth/invite/{token} Pre-auth SPA bootstrap
GET /auth/oidc/{name}/start · /callback The OIDC flow
GET /posters/{kind}/{id}/poster.jpg Poster proxy

Media probe

Technical details read from your files with ffprobe — resolution, codecs, duration, bitrate. See Configuration Reference for the config side.

media_info is a nullable object on MediaFile (movies) and Episode responses:

{
  "container": "matroska",
  "video_codec": "hevc",
  "width": 3840,
  "height": 1608,
  "duration_seconds": 8130,
  "audio_codec": "eac3",
  "audio_channels": 6,
  "bitrate": 24500000,
  "probed_at": "2026-08-18T12:00:00Z"
}

It's absent until the file has been probed, and absent again if the probe failed — check for the key, don't assume it's always there. There's no per-stream breakdown (no audio_tracks/subtitles) in this release.

ffmpeg_warn on GET /system/info is true when ffmpeg.enabled is true but ffprobe wasn't found on this process — a misconfigured ffmpeg.path or a custom build missing the binaries. The key is absent when ffmpeg.enabled is false; the operator opted out, so it's not a warning.

GET/PATCH /config/ffmpeg (admin) reads and edits the runtime config:

api "$SL/api/v1/config/ffmpeg"
# {"enabled":true,"path":"","found":true,"resolved_path":"/usr/local/bin/ffprobe"}

api -X PATCH -d '{"enabled":false}' "$SL/api/v1/config/ffmpeg"

found and resolved_path are derived from the current process's live prober, not the config file — read-only, sending them in the PATCH body has no effect. path only takes effect on the next restart, since the prober is built once at boot.


Worked examples

export SL=https://streamline.example.com
export KEY=your-api-key
api() { curl -sS -H "X-API-Key: $KEY" -H 'Content-Type: application/json' "$@"; }

Add a movie by TMDB ID:

api -X POST -d '{"tmdb_id":603,"quality_profile":"default"}' "$SL/api/v1/movies"

Find everything still wanted:

api "$SL/api/v1/movies?limit=100" | jq '.items[] | select(.status=="wanted") | .title'

Trigger a search for every wanted movie:

api -X POST "$SL/api/v1/schedules/movie-missing-search/run"

Add a show, monitoring only missing episodes:

api -X POST -d '{"tvdb_id":81189,"preset":"missing"}' "$SL/api/v1/series"

preset is one of all, future, missing, existing, pilot, none, and is applied once at add time to the season/episode tree.

Approve every pending request:

api "$SL/api/v1/requests?status=pending" \
  | jq -r '.items[].id' \
  | xargs -I{} curl -sS -X POST -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
      -d '{}' "$SL/api/v1/requests/{}/approve"

Nagios/Prometheus-style health check:

curl -fsS "$SL/health" >/dev/null && echo OK

Watch the download queue:

watch -n5 "curl -sS -H 'X-API-Key: $KEY' $SL/api/v1/activity/queue \
  | jq -r '.items[] | \"\(.status)\t\(.progress)\t\(.title)\"'"

Add an indexer:

api -X POST -d '{
  "name":"prowlarr",
  "protocol":"prowlarr",
  "host":"prowlarr",
  "port":9696,
  "api_key":"...",
  "enabled":true
}' "$SL/api/v1/indexers"

api -X POST "$SL/api/v1/indexers/prowlarr/test"

Generating a client

The spec is standard OpenAPI 3.0.4, so any generator works:

curl -fsSL -o openapi.yaml https://streamline.example.com/api/v1/openapi.yaml

# TypeScript
npx openapi-typescript openapi.yaml -o streamline.d.ts

# Python / Kotlin / Swift / …
npx @openapitools/openapi-generator-cli generate \
  -i openapi.yaml -g python -o ./client

Building a mobile client is an explicitly supported use case — the API was designed for it, which is why every UI capability has an endpoint behind it.

Clone this wiki locally