jellyfin
Query your Jellyfin media server from the terminal — recently added media,
Install
npx skills add https://github.com/magnus919/agent-skills/tree/main/jellyfin
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install magnus919-agent-skills@llmmart
git clone https://github.com/magnus919/agent-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole magnus919/agent-skills collection as a plugin from our marketplace. Git is the plain clone.
README
Jellyfin Media Server from the Terminal
Query your Jellyfin media library — recently added movies and episodes, search and inspect items, walk series, seasons, and episodes, browse library contents, see next-up episodes, log in as a user, and check server stats.
Why Install This Skill
When your agent loads this skill, it can navigate your home media server without opening a browser. That means:
- See what's new — recently added movies and TV episodes, filtered server-side
- Search your library — find any movie, show, or episode by keyword
- Navigate series — walk a show's seasons and episodes, and see what's next unwatched
- Browse collections — list your libraries and page through everything in them
- Authenticate properly — log in as a user (or use Quick Connect) without fumbling
Jellyfin's unusual
MediaBrowserauthorization header, which trips up most scripts - Check server details — server name, version, operating system, user count, counts
Every command is read-only (plus a login helper), and --dry-run previews any request
without touching the network.
What You Get
| Path | Purpose |
|---|---|
SKILL.md |
Complete command reference with setup, gotchas, and recipes |
scripts/jellyfin |
CLI for Jellyfin API operations (--json, --dry-run) |
scripts/test_jellyfin_cli.py |
Offline test suite (all HTTP mocked) |
references/auth-and-sessions.md |
The MediaBrowser header scheme, login flow, token channels, deprecation timeline |
references/endpoint-catalog.md |
Endpoint-by-endpoint parameter and response-shape catalog |
references/user-scoping-and-errors.md |
Which calls need a user id, and why queries 400/404 without one |
references/gotchas-field-guide.md |
Wire-level failure signatures and version differences |
references/worked-recipes.md |
Multi-step curl/jq and CLI workflows |
references/quick-connect.md |
Passwordless Quick Connect login |
evals/evals.json |
Behavioral eval cases including negative triggers |
Quick Start
scripts/jellyfin --help
export JELLYFIN_URL="http://your-server:8096"
export JELLYFIN_API_KEY="your-api-key" # Dashboard → API Keys
export JELLYFIN_USER_ID="your-jellyfin-user-id" # required by user-scoped commands
scripts/jellyfin search --query "dune" --type Movie --json
scripts/jellyfin recent --movies --limit 5
No API key yet? Log in as a user instead — the script sends the pre-token
Authorization: MediaBrowser Client=..., Device=..., DeviceId=..., Version=... header
that POST /Users/AuthenticateByName requires and prints the values to export:
scripts/jellyfin login --username alice --prompt
Triggers
Load this when asking about Jellyfin, media server content, recently added movies or TV, next-up episodes, browsing your home media library, or Jellyfin API authentication.
Requirements
Python 3.8+ with requests. A running Jellyfin server (10.8+ behaviors assumed).
Authentication: an API key (Dashboard → API Keys), a user access token via login, or
Quick Connect. User-scoped commands also need a Jellyfin user id.
Skill manifest
jellyfin — Jellyfin Media Server from the Terminal
Query recently added movies and TV episodes, search and inspect media, walk series →
seasons → episodes, browse libraries, see next-up episodes, log in as a user, and check
server stats — all from your Jellyfin server's REST API. Every command is read-only
except login.
Setup
- Make sure your Jellyfin server is running and accessible (default
http://localhost:8096). - Pick an authentication route:
- API key — Dashboard → API Keys →
+. Administrator-level, no user identity: every user-scoped command then needs an explicit user id. - User token — run
scripts/jellyfin login --username NAME --promptonce; it prints the values to export.
- API key — Dashboard → API Keys →
- Set these environment variables:
export JELLYFIN_URL="http://your-server:8096" # include protocol and port
export JELLYFIN_API_KEY="your-api-key-here" # or JELLYFIN_TOKEN after `login`
export JELLYFIN_USER_ID="your-jellyfin-user-id" # required by recent, next-up, item, seasons, episodes
Run the bundled CLI as scripts/jellyfin. --help and --dry-run work without
credentials.
How authentication works
Jellyfin wants a MediaBrowser-scheme Authorization header on every call. The login
endpoint requires its Client=..., Device=..., DeviceId=..., Version=... quartet before
any token exists — the server rejects POST /Users/AuthenticateByName with
400 Error processing request. otherwise. Afterwards the access token (or API key) rides
the same header as Token="..."; the legacy X-Emby-Token header means the same thing
and is scheduled for removal from Jellyfin 12.0. The bundled CLI sends the modern form and
puts the token in exactly that one channel per request (never co-sends X-Emby-Token).
See references/auth-and-sessions.md.
Essential Commands
Authentication — get a session
scripts/jellyfin login --username alice --prompt # prints JELLYFIN_* exports
echo "pw" | scripts/jellyfin login --username alice --password-stdin
scripts/jellyfin login --username alice --dry-run --json # preview the pre-token header
login demonstrates the full researched sequence: complete pre-token MediaBrowser header
→ POST /Users/AuthenticateByName → capture User.Id + AccessToken → print the
post-token header for reuse. It never echoes the password.
info — Server information
scripts/jellyfin info # server name, version, OS, user count
scripts/jellyfin info --json
recent — Recently added media
scripts/jellyfin recent # last 10 items added for JELLYFIN_USER_ID
scripts/jellyfin recent --movies --limit 5 # server-side includeItemTypes filter
scripts/jellyfin recent --episodes --limit 20 --json
scripts/jellyfin recent --user-id USER_ID # override the env var
Hits /Items/Latest with userId; the response is a bare JSON array (no Items
wrapper), and groupItems merges episodes by series, so treat it as "what's new".
search — Search your media library
scripts/jellyfin search --query "dune" # everything
scripts/jellyfin search --query "dune" --type Movie # comma-separated types
scripts/jellyfin search --query "star trek" --type Series,Episode --limit 5 --json
Search hits /Search/Hints; results carry id (with a deprecated ItemId twin on old
servers — the CLI already prefers the modern field).
Navigation — inspect items and walk series
scripts/jellyfin search --query "dune" --type Movie --json # find an item ID
scripts/jellyfin item --id ITEM_ID # full metadata (needs user)
scripts/jellyfin seasons --series-id SERIES_ID # list seasons
scripts/jellyfin episodes --series-id SERIES_ID --season-id SEASON_ID
scripts/jellyfin next-up --limit 10 # next unwatched episodes
scripts/jellyfin next-up --series-id SERIES_ID --user-id USER_ID
item, seasons, episodes, and next-up are user-scoped: they require
JELLYFIN_USER_ID or --user-id and fail before any network call without one.
libraries — browse a collection
scripts/jellyfin libraries # library IDs and types
scripts/jellyfin browse --library-id LIBRARY_ID --type Movie --limit 50
scripts/jellyfin browse --library-id LIBRARY_ID --start-index 50 # paginate
scripts/jellyfin browse --library-id LIBRARY_ID --user-id USER_ID # userId sent explicitly
libraries reads /Library/MediaFolders, which is admin-only — non-admin tokens get
403 and should use /UserViews (see references). browse pages /Items with
startIndex/limit and passes userId when provided, since servers using non-API-key
auth reject unscoped queries with 400 userId is required.
stats — Library statistics
scripts/jellyfin stats # movie, series, episode, song counts (/Items/Counts)
Pipeline recipes
Find a series, then its next unwatched episode
scripts/jellyfin search --query "breaking bad" --type Series --json | jq -r '.results[0].id'
scripts/jellyfin next-up --series-id "$SERIES_ID" --user-id "$JELLYFIN_USER_ID" --json | jq -r '.items[0].name'
Page through a whole library
scripts/jellyfin browse --library-id "$LIB_ID" --limit 100 --start-index 0 --json | jq -c '.items'
# loop: advance --start-index by the returned count until .total_record_count is reached
Log in and persist a session
scripts/jellyfin login --username alice --prompt --json | jq -r '"\(.user_id) \(.access_token)"'
JSON and jq
Put --json before or after the subcommand. Output keys are stable snake_case: items
(with id, name, type, year, series, season_number, episode_number),
results, libraries, total_record_count, start_index. --dry-run emits a plan
carrying dry_run, path, and params (login adds authorization_header; info
composes a requests list), matching what would be sent, so jq can verify a chain before
running it live. Exit codes: 0 success (including dry-run), 1 CLI/API errors, 2
argument errors. Use jq -r '.items[] | [.name, .year] | @tsv' for tabular handoff.
Known Gotchas
- JELLYFIN_URL must include protocol and port — e.g.
http://192.168.1.100:8096. - User-scoped commands require an explicit user —
recent,next-up,item,seasons,episodesrefuse to run withoutJELLYFIN_USER_ID/--user-id. The CLI never picks an administrator for you. A missing userId on user-token requests makes the server answer400 userId is required. - API keys have no user —
/Users/Meanswers400 Token is not owned by a user.to API keys by design; per-user queries need an explicit user id (see references/user-scoping-and-errors.md). - The login 400 vs 401 trap — missing/partial MediaBrowser header →
400with plain textError processing request.; wrong credentials →401. Same endpoint, different failures. - Response shapes differ per endpoint —
/Itemsand/Shows/*wrap results in{Items, TotalRecordCount, StartIndex};/Items/Latestreturns a bare array; search uses aSearchHintskey. Generic clients must branch (the CLI already does). - Recent type filtering is server-side —
--movies/--episodesbecomeincludeItemTypesbeforelimit; no local filtering. - NextUp needs userId on every server version — omitting it crashed servers ≤10.8 and silently scopes to the session user on ≥10.9. The CLI always sends it.
librariesis admin-only —/Library/MediaFoldersrequires an administrator token; non-admin tokens get 403.- Legacy auth is going away —
X-Emby-Token,X-MediaBrowser-Token, and theapi_keyquery parameter are deprecated; admins can already disable them (10.11+), and removal targets 12.0. Prefer the modern Authorization header the CLI sends. - Lazy auth —
--helpand--dry-runwork without credentials; dry-run never touches the network.
When to use
Use this skill for read-only interaction with a running Jellyfin server: discovery of
what's new, searching and inspecting items, walking series and seasons, next-up planning,
library inventories, and obtaining a user session via login or Quick Connect.
When not to use
Do not use this skill for server installation or administration (installing Jellyfin or
Emby, editing libraries, managing users) — every bundled command is read-only except
login. It does not target Plex or Kodi (different APIs — use their own tools), and it is
not a playback remote: route streaming or remote-control automation to Jellyfin's official
clients.
Reference Files
| File | Use it for |
|---|---|
| references/auth-and-sessions.md | MediaBrowser header scheme, login flow, token channels, legacy deprecation, error signatures |
| references/endpoint-catalog.md | Every read endpoint's parameters, response shapes, image URLs, pagination loop |
| references/user-scoping-and-errors.md | The userId requirement matrix, API-key identity quirks, 400-vs-404 diagnosis |
| references/gotchas-field-guide.md | Wire-level failure signatures, version-drift ledger, mock shapes |
| references/worked-recipes.md | Multi-step curl/jq and CLI recipes: login → latest, libraries → browse, search → seasons → episodes |
| references/quick-connect.md | Passwordless Quick Connect login flow |
Available Scripts and Prerequisites
scripts/jellyfin— the bundled Python CLI (--json,--dry-run, lazy auth). Imports only the standard library andrequests.scripts/test_jellyfin_cli.py— offline test suite (pytest + unittest compatible); all HTTP behavior is mocked, zero network egress.- Requires Python 3.8+ and
requests. A running Jellyfin server (10.8+ assumed; tested behaviors anchored to the 12.0-era OpenAPI spec). No service is started by this skill.
Files (agent-skills)
-
evals
-
evals.json 4.8 KB
{ "schema_version": 1, "skill_name": "jellyfin", "evals": [ { "id": "recent-movies-json", "prompt": "Show me the five movies most recently added to my Jellyfin server, as JSON.", "expected_output": "Run scripts/jellyfin recent --movies --limit 5 --json with JELLYFIN_URL, JELLYFIN_API_KEY, and JELLYFIN_USER_ID configured; the /Items/Latest response is a bare JSON array, not an {Items: [...]} wrapper.", "assertions": [ "invokes scripts/jellyfin recent with --movies and --limit 5", "uses --json for machine-readable output", "does not wrap the result in an Items key because /Items/Latest returns a bare array" ] }, { "id": "search-to-episodes-pipeline", "prompt": "Find the series Breaking Bad on my Jellyfin server and then list its episodes.", "expected_output": "Chain scripts/jellyfin search --query 'breaking bad' --type Series --json to get a result id, then scripts/jellyfin seasons --series-id <id> --user-id <user id>, then scripts/jellyfin episodes --series-id <id> --season-id <season id> --user-id <user id>, consuming each stage's id output in the next command.", "assertions": [ "starts with a search using --type Series", "extracts the id field from search results before the next stage", "walks seasons then episodes with --series-id and --user-id", "passes the user id explicitly on every user-scoped command" ] }, { "id": "authenticatebyname-pretoken-header", "prompt": "My script calls POST /Users/AuthenticateByName on Jellyfin with just the username and password JSON and gets HTTP 400 'Error processing request.' even though the credentials are correct. Why?", "expected_output": "The login endpoint requires a complete Authorization header in the MediaBrowser scheme - Client=\"...\", Device=\"...\", DeviceId=\"...\", Version=\"...\" - BEFORE any access token exists; the server uses those values to create the session and rejects the request with 400 when the header is missing. The returned AccessToken is then sent as Token=\"...\" in the same Authorization header on subsequent calls (X-Emby-Token is the deprecated legacy equivalent).", "assertions": [ "explains the pre-token MediaBrowser Client/Device/DeviceId/Version header requirement", "diagnoses the 400 Error processing request. body as the missing-header signature", "distinguishes wrong-credential 401 from missing-header 400", "notes the AccessToken is sent via Token= or legacy X-Emby-Token afterwards" ] }, { "id": "user-id-scoping-diagnosis", "prompt": "Queries against my Jellyfin server work with curl on /System/Info but GET /Items returns 400 saying 'userId is required', and /Users/Me returns 400 'Token is not owned by a user.' What is wrong?", "expected_output": "The token is a dashboard API key, which authenticates as an administrator but has NO user identity. User-scoped endpoints need an explicit userId query parameter, and /Users/Me intentionally rejects API keys. Find a user id via GET /Users and pass it (or use a user access token from login).", "assertions": [ "identifies API-key authentication as the cause", "explains userId must be passed explicitly on /Items and other user-scoped endpoints", "resolves it by listing /Users or logging in for a user token" ] }, { "id": "latest-shape-and-pagination-gotcha", "prompt": "Parse Jellyfin recently-added output in a generic client: which response shapes differ across endpoints, and how should paging loop over /Items?", "expected_output": "/Items, /Shows/*, and /Search/Hints return a wrapper object {Items, TotalRecordCount, StartIndex}; /Items/Latest returns a bare array of items; /Items/{id} returns a single object. Page /Items with startIndex plus limit and stop on an empty page or when startIndex reaches TotalRecordCount, since counts can go stale mid-scan.", "assertions": [ "distinguishes wrapper-object, bare-array, and single-object response shapes", "names /Items/Latest as the bare-array exception", "describes startIndex/limit paging with an empty-page guard" ] }, { "id": "emby-install-not-for-jellyfin", "prompt": "Help me install an Emby server on my NAS and migrate my Plex library into it.", "expected_output": "This must not trigger the jellyfin skill: server installation, Emby/Plex administration, and media-ripper workflows are outside its read-only Jellyfin query scope. Use OS package tooling for the install and the target server's own migration tooling.", "assertions": [ "must not trigger jellyfin for Emby or Plex server installation", "refuses server administration and library-migration work", "points to OS tooling instead of the read-only Jellyfin query CLI" ] } ] }
-
-
references
-
auth-and-sessions.md 10.5 KB
# Jellyfin authentication and sessions How tokens are minted, how they travel, and what each failure looks like on the wire. Every behavioral claim here traces to the canonical OpenAPI spec served from api.jellyfin.org, the Jellyfin server source code, or a document authored by a Jellyfin core developer (Sources footer). ## The two token postures Jellyfin has two credential families plus anonymous access: | Posture | Obtained via | Lifetime | Identity | Typical use | | --- | --- | --- | --- | --- | | User access token | `POST /Users/AuthenticateByName` (or Quick Connect) | Session-based, valid until logout or revocation | Bound to one user (and one device id) | Acting as a person; playstate, per-user views | | API key | Dashboard → API Keys (admin panel) | Persistent until revoked | No user identity; administrator-level privileges | Server automation, read-only browsing CLIs | Anonymous access (no header at all) works only for endpoints that opt in, notably `GET /System/Info/Public`, `GET /Users/Public`, and `POST /QuickConnect/Initiate`. `GET /System/Info/Public` is the recommended pre-auth probe: it returns `ServerName` and `Version` without credentials, which is how a client learns the server version before adapting its behavior. An API key is not a lesser credential: it bypasses user identity entirely and gets administrator role. It also means every user-scoped concept (per-user views, playstate, "my" recent items) has no one to attach to unless you pass an explicit `userId` parameter. ## The MediaBrowser authorization scheme (required BEFORE any token exists) Jellyfin's `Authorization` header uses a custom scheme named `MediaBrowser` with comma-separated, order-insensitive `Key="value"` parameters: ``` Authorization: MediaBrowser Client="my-cli", Device="terminal", DeviceId="unique-device-id", Version="1.0.0" ``` Parameter keys (case-sensitive, alphanumeric, unknown keys ignored by the server): | Key | Meaning | | --- | --- | | `Client` | Name of the client application | | `Device` | Human-readable device name | | `DeviceId` | Client-generated unique device identifier | | `Version` | Client application version | | `Token` | Access token or API key — present only AFTER you have one | Values must be wrapped in double quotes and should be URL-encoded; the official TypeScript SDK wraps every value in `encodeURIComponent`. The server URL-decodes values after parsing. **The login chicken-and-egg.** `POST /Users/AuthenticateByName` must be sent with a COMPLETE `Client`/`Device`/`DeviceId`/`Version` header — before any token exists. The server uses those four strings as the new session's identity: the controller builds an `AuthenticationRequest` from the parsed header values, and `SessionManager` hard-fails with `ArgumentException.ThrowIfNullOrEmpty` on any missing `App`, `DeviceId`, `DeviceName`, or `AppVersion`. The exception middleware maps `ArgumentException` to HTTP 400, so the classic CLI failure mode — sending no header, or only `Content-Type` — looks like this on the wire: ``` HTTP/1.1 400 Bad Request Content-Type: text/plain Error processing request. ``` (Non-development servers replace the real exception text with the literal string `Error processing request.`.) A bare-token `Authorization: <key>` without the MediaBrowser scheme fails the same class of parse and was observed as 401 in issue #12990; the correctly-wrapped header succeeded. DeviceId hygiene: the server permits a single access token per `DeviceId`, and re-logging-in the same `(DeviceId, user)` pair silently revokes that pair's previous token — mix a per-profile discriminator (e.g. hashed username) into the DeviceId when one machine drives several accounts. ## Exact login flow Request (note: full MediaBrowser header, NO Token segment yet): ``` curl -X POST "http://localhost:8096/Users/AuthenticateByName" \ -H "Content-Type: application/json" \ -H 'Authorization: MediaBrowser Client="my-cli", Device="terminal", DeviceId="dev-1", Version="1.0.0"' \ -d '{"Username": "alice", "Pw": "secret"}' ``` Body schema `AuthenticateUserByName`: `Username` (string) and `Pw` (PLAIN TEXT password). There is an older `Password` sha1-hash slot in some legacy documentation — do not use it; send plaintext in `Pw`. Response 200 (`AuthenticationResult`; properties shown PascalCase, the server default): ```json { "User": { "Name": "alice", "Id": "6eec632a-ff0d-4d09-aad0-bf9e90b14bc6", "HasPassword": true }, "SessionInfo": { "Id": "a1b2c3d4e5f6", "UserId": "6eec632a-ff0d-4d09-aad0-bf9e90b14bc6", "UserName": "alice", "Client": "my-cli", "DeviceId": "dev-1", "DeviceName": "terminal", "ApplicationVersion": "1.0.0" }, "AccessToken": "<ACCESS_TOKEN>", "ServerId": "abc123def456" } ``` Capture `User.Id` (this is the user id every user-scoped endpoint wants) and `AccessToken`. The spec marks the operation's only documented non-200 as 503 (server starting); credential failures arrive via exception mapping instead: wrong username/password → 401 ("Invalid username or password entered." in server logs), disabled user or device/session policy rejection → 403, missing/partial MediaBrowser header → 400 as above. After login, `GET /Users/Me` with the token is the cleanest identity re-check; it returns the authenticated user's `UserDto`. With an API key instead of a user token, `/Users/Me` answers 400 with the JSON body `Token is not owned by a user.` — API keys are userless. ## Sending the token afterwards | Channel | Form | Status | | --- | --- | --- | | `Authorization` header, full scheme | `Authorization: MediaBrowser Client="c", Device="d", DeviceId="i", Version="v", Token="<token>"` | Recommended | | `Authorization` header, token-only | `Authorization: MediaBrowser Token="<api-key>"` | Valid (API-key example in official docs) | | Query parameter | `?ApiKey=<token>` | Discouraged (leak risk in logs); never combine with the header | | `X-Emby-Token` header | `X-Emby-Token: <token>` | Deprecated (legacy) | | `X-MediaBrowser-Token` header | `X-MediaBrowser-Token: <token>` | Deprecated (legacy) | | `X-Emby-Authorization` header | full MediaBrowser scheme on a legacy header name | Deprecated (legacy) | The bundled `scripts/jellyfin` sends the modern form: `Authorization: MediaBrowser Client="...", Device="...", DeviceId="...", Version="...", Token="<token-or-api-key>"`. It is wire-equivalent to the legacy `X-Emby-Token` header on every server that supports legacy auth, and it keeps working when legacy channels are switched off. Never send two different tokens in one request — server precedence across channels is unspecified at the contract level and the value used becomes uncertain. The bundled CLI takes this literally: after login it puts the token in exactly ONE channel per request (the `Token=` parameter of the modern header) and never attaches `X-Emby-Token` alongside it; the offline suite pins that single-channel contract with a request-capture test. ### Legacy kill-switch and deprecation timeline - All legacy channels above are gated by server config `EnableLegacyAuthorization` (`system.xml`), default **true** through 10.11.x. Setting it to `false` (introduced in 10.11) makes `X-Emby-Token`, `X-MediaBrowser-Token`, `api_key`, and `X-Emby-Authorization` stop resolving — a client that "worked yesterday" and now gets uniform 401s has almost certainly met this toggle. - Maintainers have targeted disabling the deprecated options starting with the 12.0 release. Speak the modern `Authorization` header natively; if you must support a server with legacy auth locked off, substitute the legacy header for the modern one on that server's requests — never send both on the same request. ## Error signatures worth mocking | Scenario | Status | Body | | --- | --- | --- | | AuthenticateByName without (full) MediaBrowser header | 400 | text/plain `Error processing request.` | | AuthenticateByName with wrong credentials | 401 | text/plain `Error processing request.` | | Disabled user / device or session policy reject | 403 | text/plain | | Token matches nothing (garbage token on a secured read) | 403 | `Invalid token.` (SecurityException mapping; some doc renders simplify this to 401 — trust the middleware mapping) | | Secured read with no token at all | 401 | challenge | | API key on `GET /Users/Me` | 400 | JSON ProblemDetails containing `Token is not owned by a user.` | | Server starting / restarting | 503 | HTML/text body with `Retry-After: <seconds>` and `Message: <reason>` headers | The 503 can appear on ANY endpoint during startup; retry loops should honor `Retry-After`. ## Quick Connect (passwordless alternative) For shared or headless setups where you do not want to handle a password: 1. `POST /QuickConnect/Initiate` (no auth) → 200 `QuickConnectResult` with `Secret`, `Code`, `Authenticated:false`. A 401 here means Quick Connect is disabled on that server. 2. Poll the quick-connect state endpoint (about every 5 seconds) until `Authenticated` flips to `true`, while the user approves the `Code` in their client. 3. `POST /Users/AuthenticateWithQuickConnect` with body `{"Secret": "<secret>"}` → 200 `AuthenticationResult` — same capture and follow-up as the password flow. ## Sources - https://api.jellyfin.org/ — official Jellyfin API reference (ReDoc), version 12.0.0 stable - https://api.jellyfin.org/openapi/jellyfin-openapi-stable.json — canonical OpenAPI spec (AuthenticateByName schema, 503 blocks, /Users/Me 400) - https://gist.github.com/nielsvanvelzen/ea047d9028f676185832e51ffaf12a6f — "Jellyfin API Authorization" by a Jellyfin core developer (MediaBrowser scheme, legacy table, kill-switch steps, removal-to-12.0 quote) - https://mintlify.wiki/jellyfin/jellyfin/api/authentication/overview — official server docs, authentication overview (login curl examples, API key vs user token table, logout) - https://jmshrv.com/posts/jellyfin-api/ — community API overview by the Jellyfin for Jellyfin/Roku author - https://typescript-sdk.jellyfin.org/ — official TypeScript SDK (getAuthorizationHeader construction, login-then-update flow) - https://kotlin-sdk.jellyfin.org/guide/authentication.html — official Kotlin SDK authentication guide (401-on-bad-credentials, Quick Connect cadence) - https://github.com/jellyfin/jellyfin/issues/12990 — wire-level reproduction of missing/bare-token header failures - https://github.com/jellyfin/jellyfin — server source: `AuthorizationContext.cs`, `SessionManager.cs`, `UserController.cs`, `ExceptionMiddleware.cs`, `AuthService.cs`, `CustomAuthenticationHandler.cs`, `ServerConfiguration.cs` - https://github.com/jellyfin/jellyfin-apiclient-python — reference client implementation -
endpoint-catalog.md 10.7 KB
# Jellyfin endpoint catalog for browsing and search Read-only endpoints a browsing CLI needs. Parameter names are verbatim from the canonical stable OpenAPI spec (api.jellyfin.org). Casing rule resolved there: **query parameters are camelCase** (`sortBy`, `sortOrder`, `startIndex`, `includeItemTypes`, `parentId`, `searchTerm`), while **JSON payload properties are PascalCase in the default profile** (`Items`, `TotalRecordCount`, `Name`, `Id`, `AccessToken`). Some servers emit camelCase properties depending on the response profile; treat casing as a compatibility minefield and normalize client-side. ## Auth posture per endpoint | Endpoint | Method | Auth | | --- | --- | --- | | `/System/Info/Public` | GET | none — pre-auth version probe | | `/Users/Public` | GET | none — login-screen user list | | `/System/Info` | GET | any valid token (API key or user token) | | `/Users` | GET | any valid token; params `isHidden`, `isDisabled` | | `/Users/Me` | GET | user token; 400 "Token is not owned by a user." with an API key | | `/Users/AuthenticateByName` | POST | none (but see auth reference: MediaBrowser header mandatory) | | `/UserViews?userId=` | GET | any token — the per-user library list | | `/Library/MediaFolders` | GET | **admin-only** (RequiresElevation policy) | | `/Items` | GET | token; `userId` required unless API-key auth | | `/Items/Latest` | GET | token; `userId` param | | `/Items/{itemId}` | GET | token; `userId` optional | | `/Items/Counts` | GET | token; `userId` optional | | `/Search/Hints` | GET | token; `searchTerm` required | | `/Shows/{seriesId}/Seasons` | GET | token; `userId` param | | `/Shows/{seriesId}/Episodes` | GET | token; `userId` param | | `/Shows/NextUp` | GET | token; `userId` param | `GET /Users/{userId}/Items` still routes (legacy twin of `/Items?userId=`); controllers mark the `/Users/{userId}/Views` route `[Obsolete]`. Prefer `/Items` and `/UserViews` on any recent server. ## /System/Info and /System/Info/Public `/System/Info/Public` (no auth) returns `PublicSystemInfo`: `ServerName`, `Id`, `Version`, `ProductName`. Use it as the cheap pre-flight: learn the version before choosing between behaviors that differ across server releases. `/System/Info` (token) adds `OperatingSystem`, `HasUpdateAvailable`, and more; the bundled `info` command calls `/System/Info` plus `/Users` to count users. ## /UserViews — the user's libraries `GET /UserViews?userId=<id>` returns a `BaseItemDtoQueryResult` of the libraries (views) that user can see: `Items[]` with `Id`, `Name`, `CollectionType` (`movies`, `tvshows`, `music`, ...), `TotalRecordCount`. This is the correct "list my libraries" endpoint for any token; `/Library/MediaFolders` lists raw library folders but requires an administrator token (403 Forbidden otherwise) and is not user-scoped. ## /Items — the workhorse query `GET /Items` declares ~88 query parameters in the stable spec. The core browsing set: | Param | Meaning | | --- | --- | | `userId` | **Required unless authenticating with an API key.** Missing on a user-token request → 400 with body `userId is required`. | | `parentId` | Localize the query to one folder/view; omit for the root | | `recursive` | Recurse into subfolders (use with `parentId` to enumerate a whole view) | | `includeItemTypes` | Comma-delimited item types (see enum below) | | `excludeItemTypes` | Comma-delimited inverse filter | | `sortBy` | Comma-delimited sort keys: `SortName`, `DateCreated`, `PremiereDate`, `CommunityRating`, `Random`, `ProductionYear`, `ParentIndexNumber`, `IndexNumber`, ... | | `sortOrder` | `Ascending` or `Descending` (comma-delimited to match multi-key sorts) | | `startIndex`, `limit` | Paging window | | `fields` | Comma-delimited extra fields to populate (see below) | | `filters` | `IsUnplayed`, `IsPlayed`, `IsFavorite`, `IsResumable`, ... | | `searchTerm` | Term filter inside `/Items` | | `isPlayed`, `isFavorite` | Boolean filters | | `genres`, `years`, `studios`, `artists`, `person`, `tags` | Facet filters | | `enableTotalRecordCount` | Default true; server may skip computing `TotalRecordCount` when false | Item type enum (`BaseItemKind`, the values you actually use): `Movie`, `Series`, `Season`, `Episode`, `BoxSet`, `MusicAlbum`, `MusicArtist`, `Audio`, `Photo`, `PhotoAlbum`, `Book`, `AudioBook`, `Playlist`, `Trailer`, `Channel`, `Folder`, `UserView`, `Genre`, `Studio`, `Person`, `Year`. `fields` enum members include `Overview`, `Genres`, `People`, `Path`, `MediaSources`, `MediaStreams`, `ProviderIds`, `Tags`, `DateCreated`, `ChildCount`, `RecursiveItemCount`, `PrimaryImageAspectRatio`, `SortName`, `OriginalTitle`, `Etag`. Response 200 is a `BaseItemDtoQueryResult` OBJECT — always this wrapper: ```json { "Items": [ { "Name": "Arrival", "Id": "72c5b8e6-...", "Type": "Movie" } ], "TotalRecordCount": 137, "StartIndex": 0 } ``` Error contract: `400` (text body `userId is required`) when `userId` is absent on non-API-key auth; `404` when a supplied-but-nonexistent `userId` fails lookup (the user lookup happens before the missing-userId guard, so invalid id ≠ absent id); `401`/`403` per the auth matrix. ## /Items/Latest — recently added (returns a bare ARRAY) `GET /Items/Latest` params: `userId`, `parentId`, `fields`, `includeItemTypes`, `isPlayed`, `enableImages`, `imageTypeLimit`, `enableImageTypes`, `enableUserData`, `limit` (**default 20**), `groupItems` (**default true** — groups episodes by series and movies by edition). **Shape trap: the 200 response is a bare JSON ARRAY of `BaseItemDto` — NOT a `BaseItemDtoQueryResult` wrapper.** There is no `Items` key, no `TotalRecordCount`, no `StartIndex`. Clients that unwrap `.Items` unconditionally break here (that is exactly the shape branch the bundled CLI handles in `cmd_recent`). Because grouping can merge an entire series into one entry, and the item ids in the array are per-entry, treat `groupItems=true` results as "what's new", not "how many". Filter by type server-side with `includeItemTypes` (e.g. `Movie,Series,Episode`); the requested `limit` then applies to the selected types. ## /Search/Hints — fast fuzzy search `GET /Search/Hints` requires `searchTerm`; optional `startIndex`, `limit`, `userId` ("search within a user's library or omit to search all"), `includeItemTypes`, `excludeItemTypes`, `mediaTypes` (`Unknown,Video,Audio,Photo,Book`), `parentId`, and boolean includes (`includePeople`, `includeMedia`, `includeGenres`, `includeStudios`, `includeArtists`, all default true). Response 200: ```json { "SearchHints": [ { "Id": "e60d4fa5-...", "ItemId": "e60d4fa5-...", "Name": "Breaking Bad", "Type": "Series", "ProductionYear": 2008, "MatchedTerm": "break", "Series": "..." } ], "TotalRecordCount": 3 } ``` SearchHint carries BOTH `Id` and `ItemId` with the same value — `ItemId` is marked deprecated in the spec; read `Id` and fall back to `ItemId` on old servers. Hints also surface people/genres/studios as pseudo-results when those includes are on, which `/Items` does not do; hints honor fewer filters and no `sortBy`. ## TV navigation family | Endpoint | Params | Response | | --- | --- | --- | | `/Shows/{seriesId}/Seasons` | `seriesId` (path, required), `userId`, `fields`, `isSpecialSeason`, `isMissing` | `BaseItemDtoQueryResult` of `Season` items (`IndexNumber` 0 = specials convention) | | `/Shows/{seriesId}/Episodes` | above plus `season` (int) or `seasonId` (Guid), `startItemId`, `startIndex`, `limit`, `sortBy` (SCALAR here, unlike /Items' comma array) | `BaseItemDtoQueryResult` of `Episode` items | | `/Shows/NextUp` | `userId`, `startIndex`, `limit`, `fields`, `seriesId`, `parentId`, `nextUpDateCutoff` (ISO date-time), `enableTotalRecordCount`, `enableResumable` (default true), `enableRewatching` (default false) | `BaseItemDtoQueryResult` of `Episode` items | Prefer `seasonId` (GUID) over numeric `season` — season numbers shift when specials are inserted. Season-less `/Shows/{seriesId}/Episodes` returns every episode across seasons in order. `enableRewatching` defaults to false: a fully-watched series never reappears in NextUp unless opted in. ## /Items/{itemId} and /Items/Counts `GET /Items/{itemId}?userId=<id>` returns one `BaseItemDto` (~155 properties: `Name`, `Id`, `Type`, `SeriesId`, `SeasonId`, `SeriesName`, `IndexNumber`, `ParentIndexNumber`, `RunTimeTicks`, `ProductionYear`, `Overview`, `Genres`, `MediaSources`, `ImageTags`, `BackdropImageTags`, `UserData`, `ProviderIds`, ...). `userId` is optional — supply it to get that user's `UserData` (playstate, favorites) embedded. Missing item → 404. `GET /Items/Counts?userId=<id>` returns the `ItemCounts` object: `MovieCount`, `SeriesCount`, `EpisodeCount`, `SongCount`, `AlbumCount`, `ArtistCount`, `TrailerCount`, `BoxSetCount`, `BookCount`, `MusicVideoCount`, `ProgramCount`, `ItemCount`. ## Images (read) `GET /Items/{itemId}/Images/{imageType}` serves image bytes for `Primary`, `Logo`, `Thumb`, `Backdrop`, `Banner`, and more. Tunables: `maxWidth`/`maxHeight`, `quality`, `fillWidth`/ `fillHeight`, `tag` (supply the `ImageTags` value from the DTO to get long-lived cacheable URLs), `format`. A 404 is the documented response when the item simply has no such image — treat it as normal fallback flow, not an error. Backdrops are indexed: `BackdropImageTags[i]` pairs with `/Items/{itemId}/Images/Backdrop/{i}`. ## Pagination pattern (that actually works) `/Items`, `/Shows/*`, and `/Search/Hints` page with `startIndex` + `limit` and report `TotalRecordCount`. Loop with BOTH guards — counts can go stale mid-scan on a live server: ```python start, page = 0, 100 while True: r = get_items(user_id, parent_id, start_index=start, limit=page) items = r.get("Items", []) if not items: break # short/empty page = done, even if count disagrees yield from items start += len(items) if r.get("TotalRecordCount") and start >= r["TotalRecordCount"]: break ``` `/Items/Latest` has no pagination at all — it is a single array capped by `limit`. ## Sources - https://api.jellyfin.org/ — official Jellyfin API reference (ReDoc), version 12.0.0 stable - https://api.jellyfin.org/openapi/jellyfin-openapi-stable.json — canonical OpenAPI spec (all parameter tables, enums, response schemas, 400/401/403/404/503 blocks) - https://github.com/jellyfin/jellyfin — server source confirming behavior: `ItemsController.cs` (userId 400/404 ordering, legacy /Users/{userId}/Items), `UserViewsController.cs` (obsolete Views route), `LibraryController.cs` (MediaFolders elevation), `TvShowsController.cs` (NextUp/Seasons/Episodes), `UserLibraryController.cs` (GET /Items/{itemId}) - https://mintlify.wiki/jellyfin/jellyfin/api/authentication/overview — official server docs (logout, /Users/Me semantics) - https://jmshrv.com/posts/jellyfin-api/ — community walkthrough of the items/search/images surface -
gotchas-field-guide.md 8.4 KB
# Jellyfin gotchas field guide Version-sensitive, wire-level failure signatures observed in server source, the OpenAPI spec, and tracked issues. Diagnostic order: auth posture → user scoping → response shape → version drift. ## Response-shape asymmetry (the biggest interop trap) Three families, three shapes — a generic client must branch: | Endpoint | 200 shape | | --- | --- | | `GET /Items`, `/Shows/*`, `/Search/Hints`, `/UserViews` | Wrapper object: `{ Items: [...], TotalRecordCount, StartIndex }` (search: `SearchHints` key) | | `GET /Items/Latest` | **Bare ARRAY** of `BaseItemDto` — no `Items` key, no `TotalRecordCount`, no `StartIndex` | | `GET /Items/{itemId}`, `AuthenticateByName` | Single object | `/Items/Latest` defaults: `limit` 20, `groupItems` true (episodes merge into series rows — so it answers "what's new", not "how many"). ## Property-casing minefield Every JSON-producing operation documents three profiles: `application/json`, `application/json; profile="CamelCase"`, `application/json; profile="PascalCase"`. Observed defaults vary between server eras and clients; SDKs read PascalCase off raw payloads while sample captures show camelCase. Normalize defensively: read both `Name` and `name`, both `AccessToken` and `accessToken`, rather than trusting one casing. Query PARAMETERS are always camelCase (`sortBy`, `startIndex`) regardless of profile. ## Error signatures on the wire | Symptom | Actual cause | | --- | --- | | 400 text/plain `Error processing request.` on login | Missing/partial `Authorization: MediaBrowser Client=..., Device=..., DeviceId=..., Version=...` header — required BEFORE any token exists (ArgumentException mapping) | | 401 on login | Wrong username/password ("Invalid username or password entered." in server logs) | | 403 on login | Disabled user, device-access policy, or `MaxActiveSessions` cap | | 401 + log `AuthenticationScheme: "CustomAuthentication" was challenged.` | Secured read with no/insufficient token | | 403 with valid-format token | Token matches nothing (`Invalid token.` via SecurityException) or permission denied — note this is 403, not 401; some doc renders simplify it to 401 | | 400 `Token is not owned by a user.` (JSON) | API key on `/Users/Me` — API keys are userless | | 400 `userId is required` (plain string body) | `GET /Items` without `userId` on non-API-key auth | | 404 on a user-scoped query | Supplied `userId` does not exist (user lookup precedes the missing-param guard) | | 503 + `Retry-After` + `Message` headers | Server starting/restarting — can hit ANY endpoint; honor `Retry-After` | | "Worked yesterday", now uniform 401s | Admin set `EnableLegacyAuthorization=false` (possible since 10.11): all `X-Emby-*`/`api_key` channels stopped resolving | No rate limiting exists in the spec — Jellyfin is self-hosted. A 429 comes from a reverse proxy, not Jellyfin. ## Auth-channel pitfalls - `X-Emby-Token`, `X-MediaBrowser-Token`, `api_key` query param, and the `X-Emby-Authorization` header are deprecated legacy channels; maintainers target disabling them from 12.0. Prefer `Authorization: MediaBrowser ... Token="..."`. - One access token per `(DeviceId, user)` pair: re-logging-in the same pair revokes the pair's previous token. Multi-profile CLIs must vary the DeviceId per profile or they will keep logging each other out. - Never send two token channels in one request; which one wins is not contractual. The bundled CLI honors this literally: the token rides only the MediaBrowser `Token=` parameter and the legacy `X-Emby-Token` header is never attached (request-capture-tested in the offline suite). ## User-scoping pitfalls - API key = administrator + no user. Everything per-user (views, latest, next-up, played state) needs an explicit `userId` parameter, and `UserData` fields stay empty otherwise. - Recent servers fall back to the token's user when `userId` is omitted on some endpoints — which makes omissions work on your server and fail on someone else's API-key deployment. Always send it. ## TV navigation quirks - **NextUp `userId` version split:** ≤10.8 crashes with `ArgumentException: Guid can't be empty` when omitted; ≥10.9 falls back to the session user. Pass `userId` unconditionally. - **NextUp `limit` limits returned items, not series scanned.** The 2024 attempt to make `limit` prune the scan made items vanish from NextUp days later and was reverted — keep page sizes modest, expect long-tail behavior differences between 10.9.x and 10.10.x. - `enableRewatching` defaults false: a fully-watched series never reappears in NextUp. - Prefer `seasonId` (GUID) over numeric `season` on `/Shows/{seriesId}/Episodes` — season numbers shift when specials get inserted. Season `IndexNumber` 0 is the specials convention. - `sortBy` on `/Shows/{seriesId}/Episodes` is SCALAR, unlike the comma-delimited array form `/Items` accepts. ## Field-selection and caching - `BaseItemDto` declares ~155 properties but only requested `fields` populate extras; `Overview`, `Path`, `MediaSources`, `ProviderIds`, `ChildCount` are null unless asked for. `fields=DateCreated` is what makes "recently added" sorting meaningful client-side. - `ImageTags` values are cache keys for image routes; passing `tag=` yields long-lived cacheable URLs. `Etag` changes on metadata edits — treat both as opaque. - Image routes document **404 as the normal "no such image" response** — fall back to the next image type (Primary → Thumb → Parent* tags) instead of erroring. ## Version-drift ledger (what to gate on /System/Info/Public) | Behavior | 10.7–10.8 | 10.9–10.10 | 10.11 / 12 | | --- | --- | --- | --- | | Legacy auth channels | on | on, default true | toggle exists (`EnableLegacyAuthorization`); removal targeted at 12.0 | | NextUp userId omission | crash (500) | token-user fallback | same | | `/Items` userId 400 vs 404 ordering | unverified | confirmed | confirmed | | NextUp extras | `disableFirstEpisode`, `nextUpDateCutoff`, `enableRewatching` | adds `enableResumable` (default true) | same | | `api.jellyfin.org` spec label | — | — | publishes "12.0.0" branding | Pre-flight `GET /System/Info/Public` (no auth) gives the `Version` to branch on. ## Mock-test wire shapes (exact) Success paths: 1. `POST /Users/AuthenticateByName` with complete MediaBrowser header + `{"Username","Pw"}` → 200 `AuthenticationResult` (`User.Id` hyphenated lowercase UUID; `AccessToken` string). 2. `GET /Items?userId=...&parentId=...&recursive=true&includeItemTypes=Movie&sortBy=SortName&sortOrder=Ascending&startIndex=0&limit=50` → 200 `{Items: [...], TotalRecordCount: N, StartIndex: 0}`. 3. `GET /Search/Hints?searchTerm=break&limit=20` → `{SearchHints: [{Id, ItemId (deprecated twin), Name, Type, MatchedTerm, ...}], TotalRecordCount}`. 4. `GET /Items/Latest?userId=...&limit=20&includeItemTypes=Movie,Series` → 200 bare `[{...BaseItemDto}]`. Failure paths (assert status AND content-type): 5. Login without header → 400 text/plain `Error processing request.` 6. Login wrong password → 401 text/plain. 7. Reads: no token → 401 challenge; garbage token → 403. 8. API key on `/Users/Me` → 400 JSON containing `Token is not owned by a user.` 9. `/Items` absent userId (non-API-key) → 400 body literally `userId is required`. 10. `/Items` nonexistent userId → 404 `Error processing request.` 11. Any endpoint during startup → 503 with `Retry-After` and `Message` headers. ## Sources - https://api.jellyfin.org/openapi/jellyfin-openapi-stable.json — canonical OpenAPI spec (response profiles, 503 blocks, defaults, schemas) - https://github.com/jellyfin/jellyfin — server source: `ExceptionMiddleware.cs` (status mapping, body suppression), `SessionManager.cs` (session caps, token rotation), `AuthorizationContext.cs` (legacy gates), `ServerConfiguration.cs` (legacy flag), `ItemsController.cs` (400/404 ordering) - https://github.com/jellyfin/jellyfin/issues/12990 — wire-level header-failure reproduction and challenge log line - https://api.github.com/repos/jellyfin/jellyfin/pulls/9321 — NextUp userId omission crash evidence (also pulls/11956, pulls/12414, issue #12367 for the limit saga) - https://gist.github.com/nielsvanvelzen/ea047d9028f676185832e51ffaf12a6f — core-developer authorization guide (legacy table, disable steps, removal timeline) - https://kotlin-sdk.jellyfin.org/guide/authentication.html — 401-on-bad-credentials, Quick Connect cadence - https://mintlify.wiki/jellyfin/jellyfin/api/authentication/overview — official docs error table (contrast case) -
quick-connect.md 3.4 KB
# Jellyfin quick connect Quick Connect is Jellyfin's passwordless login: the server displays a short code, the user approves it on a device where they are already signed in, and your client polls until the approval lands. Use it for headless or shared setups where you do not want to handle a password — or when the user account has no password at all (send an empty-string `Pw` for those in the final exchange). ## When Quick Connect is the right flow - The CLI runs where you cannot (or should not) type a password: CI, SSH sessions, cron. - You do not want the script to ever see the user's password. - The server has Quick Connect enabled — otherwise `POST /QuickConnect/Initiate` answers **401** with "Quick connect is not active on this server" (that 401 is the disable signal, not an auth failure). ## The flow ``` 1. POST /QuickConnect/Initiate # no authentication required → 200 { "Secret": "<secret>", "Code": "123456", "Authenticated": false } → 401 = feature disabled on this server 2. Show the Code to the user; on another signed-in client they approve it (Dashboard or the client prompt). 3. Poll every ~5 seconds: GET /QuickConnect/Connect?secret={Secret} # quick-connect state → QuickConnectResult with Authenticated flipping to true when approved 4. POST /Users/AuthenticateWithQuickConnect # body: {"Secret": "<secret>"} → 200 AuthenticationResult # SAME capture as password login ``` `AuthenticationResult` is identical to the password flow's: capture `User.Id` as `USER_ID` and `AccessToken` as `TOKEN`, then send the standard `Authorization: MediaBrowser ... Token="..."` header on every subsequent call. The same session rules apply — one token per `(DeviceId, user)` pair, re-login revokes the pair's previous token — so still send a complete `Client`/`Device`/`DeviceId`/`Version` header with the `AuthenticateWithQuickConnect` call. Error paths: `400 "Missing token"` on step 4 when `Secret` is absent; the 401-on-initiate disable case above; polling forever if the user never approves — bound your loop. ## Which login flow should my client use? | Situation | Flow | | --- | --- | | Scripting with a persistent admin credential | API key from Dashboard → API Keys (`Authorization: MediaBrowser Token="<key>"`) | | Interactive one-user session | `POST /Users/AuthenticateByName` with the full pre-token MediaBrowser header | | Headless / passwordless / shared device | Quick Connect (this reference) | | Server with legacy auth disabled and a very old client | Nothing helps — upgrade the client to speak the modern header | ## Sources - https://api.jellyfin.org/openapi/jellyfin-openapi-stable.json — canonical OpenAPI spec (QuickConnect operations: Enabled, Initiate, Connect state, AuthenticateWithQuickConnect; AuthenticationResult schema) - https://kotlin-sdk.jellyfin.org/guide/authentication.html — official Kotlin SDK authentication guide (Quick Connect cadence ~5s poll, disabled-server 401, empty-password note) - https://mintlify.wiki/jellyfin/jellyfin/api/authentication/overview — official server docs (token usage after login, session lifetime) - https://jellyfin.org/docs/general/server/quick-connect — Quick Connect feature overview - https://gist.github.com/nielsvanvelzen/ea047d9028f676185832e51ffaf12a6f — core-developer authorization guide (device identity and token pairing rules) -
user-scoping-and-errors.md 5.4 KB
# Jellyfin user scoping — the userId matrix The single most common Jellyfin integration failure after authentication: a request that authenticates fine but 404s or 400s because **which user** the query is about was never stated. Jellyfin's data model is per-user at the library level; most read endpoints need you to say whose library you are looking at. ## Why user id is everywhere A Jellyfin library is not one global catalog. Views, playstate, favorites, resume points, and parental-control visibility all attach to a user. The authentication layer may or may not imply a user: - **User access token** (from `AuthenticateByName`): implies one user. Recent servers fall back to that user when `userId` is omitted on some endpoints. - **API key** (from Dashboard → API Keys): implies NO user. `AuthorizationInfo.User` stays null and the request gets administrator role. Every user-scoped concept must be named explicitly with a `userId` parameter — including `/UserViews`, which is meaningless without one. The bundled CLI always sends `userId` explicitly on user-scoped commands regardless of which credential type it holds. That is the cross-version-safe baseline. ## The userId requirement matrix | Endpoint | User token, userId omitted | API key | | --- | --- | --- | | `GET /Items` | **400** — body `userId is required` (servers enforce `if (!isApiKey && user is null) return BadRequest("userId is required")`) | Optional — omitted means an unrestricted, userless view; pass it anyway for `UserData` in DTOs | | `GET /Items/{itemId}` | Defaults to the token's user; `userId` supplies whose `UserData` embeds | Pass explicitly for user data | | `GET /UserViews` | Parameter accepted; pass it | REQUIRED to be meaningful | | `GET /Shows/{seriesId}/Seasons` / `Episodes` | ≥10.9 falls back to token user; ≤10.8 crashes on omission — always pass | REQUIRED | | `GET /Shows/NextUp` | Same version split as above — always pass | REQUIRED | | `GET /Items/Latest` | `userId` scopes "recently added for whom"; always pass | REQUIRED for meaningful results | | `GET /Search/Hints` | Optional — "omit to search all" | Optional | | `GET /Users/Me` | Works (returns token's user) | **400** `Token is not owned by a user.` | | `GET /System/Info`, `/System/Info/Public`, `/Users` | No user concept | Fine | Note the deliberate trap in `/Users/Me`: it is the natural "who am I" endpoint for a user token and returns exactly the id you need — but with an API key it is a 400, by design, because an API key is nobody. ## 400 vs 404: absent vs invalid userId on /Items Two distinct failure signatures, enforced in this order by the items controller (verified at master and v10.10.7): 1. The user lookup runs first: a supplied-but-nonexistent `userId` throws `ResourceNotFoundException` → mapped to **404** (`Error processing request.` body). 2. Then the guard: an ABSENT `userId` on non-API-key auth returns **400** with the literal string body `userId is required` (not the middleware's generic text). So: `400 userId is required` = you forgot the parameter; `404` = the parameter names a user that does not exist. Mock both distinctly. ## Finding a user id without logging in as one 1. **`GET /Users`** (any valid token): array of `UserDto` with `Name` and `Id`. The administrator-flavored listing — API keys see everyone. 2. **`GET /Users/Public`** (no auth): only users flagged visible on login screens. 3. **After `AuthenticateByName`**: the response's `User.Id` is the documented primary. 4. **With a user token**: `GET /Users/Me`. ```bash # Discover user ids with an API key curl -s -H 'Authorization: MediaBrowser Token="YOUR_API_KEY"' \ "http://localhost:8096/Users" | jq -r '.[] | [.Name, .Id] | @tsv' # → alice 6eec632a-ff0d-4d09-aad0-bf9e90b14bc6 ``` ## Scoping errors look like 404s The confusion this reference exists for: `GET /Items` (or `/Shows/NextUp`) called without a `userId` under a context where one is required does not answer "you forgot the user" on every endpoint and version — older servers crash (NextUp ≤10.8: 500 from an empty-Guid `ArgumentException`), and user-token fallbacks silently change results. Symptoms cluster as "endpoint exists but returns 400/404/empty" even though the token is perfectly valid. The fix is uniform: resolve the user id once, pass it explicitly on every user-scoped call. The bundled CLI mirrors that baseline: `recent`, `next-up`, and `item` require `JELLYFIN_USER_ID` or `--user-id` before any network call, refuse to guess an administrator, and dry-run previews show the `userId` that would have been sent. ## Sources - https://api.jellyfin.org/ — official Jellyfin API reference (ReDoc), version 12.0.0 stable - https://api.jellyfin.org/openapi/jellyfin-openapi-stable.json — canonical OpenAPI spec (`/Users/Me` 400 "Token is not owned by a user.", `userId` parameter descriptions across /Items, /Items/Latest, /Shows/*, /Search/Hints) - https://github.com/jellyfin/jellyfin — server source: `ItemsController.cs` (userId-required guard and 400/404 ordering), `RequestHelpers.cs` (GetUserId token fallback), `TvShowsController.cs` (NextUp userId version history), `AuthorizationContext.cs` (API key ⇒ User null + admin role) - https://mintlify.wiki/jellyfin/jellyfin/api/authentication/overview — official server docs (API key vs user token semantics) - https://gist.github.com/nielsvanvelzen/ea047d9028f676185832e51ffaf12a6f — core-developer authorization guide (API-key identity behavior) -
worked-recipes.md 8.9 KB
# Jellyfin worked recipes Multi-step workflows against a real server. Every stage consumes the previous stage's output; field names and types match the endpoint catalog. Constants: `BASE` = `http://<host>:8096` (official default port). Wire examples use PascalCase properties; your client should normalize casing (see the gotchas guide). ## Recipe 1 — Log in, capture identity, list what's new for that user The full pre-token → token → user-scoped-read sequence: ``` 1. (probe) GET /System/Info/Public # no auth → ServerName, Version 2. POST /Users/AuthenticateByName Authorization: MediaBrowser Client="my-cli", Device="terminal", DeviceId="dev-1", Version="1.0.0" # COMPLETE header, no Token yet {"Username": "alice", "Pw": "secret"} → AuthenticationResult 3. USER_ID = .User.Id TOKEN = .AccessToken # hyphenated UUID; both strings 4. Subsequent calls: Authorization: MediaBrowser Client="my-cli", Device="terminal", DeviceId="dev-1", Version="1.0.0", Token="{TOKEN}" 5. GET /Items/Latest?userId={USER_ID}&limit=20&includeItemTypes=Movie,Series,Episode → 200 BARE ARRAY of BaseItemDto (NOT a wrapper) # shape branch here ``` One-liner with curl + jq: ```bash AUTH='Authorization: MediaBrowser Client="my-cli", Device="terminal", DeviceId="dev-1", Version="1.0.0"' res=$(curl -s -X POST -H "$AUTH" -H 'Content-Type: application/json' \ -d '{"Username":"alice","Pw":"secret"}' "$BASE/Users/AuthenticateByName") user_id=$(jq -r '.User.Id' <<<"$res") token=$(jq -r '.AccessToken' <<<"$res") curl -s -H "Authorization: MediaBrowser Token=\"$token\"" \ "$BASE/Items/Latest?userId=$user_id&limit=20" | jq -r '.[] | .Name' ``` Failure branches: step 2 without the full MediaBrowser header → 400 `Error processing request.`; wrong password → 401; server restarting → 503 + `Retry-After` (retry). With the bundled CLI, steps 2–5 collapse to env vars: export `JELLYFIN_URL`, `JELLYFIN_API_KEY` (or `JELLYFIN_TOKEN`), `JELLYFIN_USER_ID`; then `scripts/jellyfin recent --json`. Post-login requests carry the token in exactly one channel — the `Token=` parameter of the MediaBrowser `Authorization` header (verified by the suite's request-capture test); the legacy `X-Emby-Token` header is not co-sent. The login path itself is available as `scripts/jellyfin login --username alice` (reads `JELLYFIN_PASSWORD` interactively or via `--password`/`--password-stdin`), which prints the captured `user_id`/`access_token` for exporting. ## Recipe 2 — Libraries → paged browse of one collection ``` 1. GET /UserViews?userId={USER_ID} # token-auth → {Items: [{Id, Name, CollectionType: "movies"|"tvshows"|..., ...}], TotalRecordCount} 2. VIEW_ID = .Items[] | select(.Name == "Movies") | .Id 3. Page loop (both guards — counts can go stale mid-scan): start = 0; PAGE = 100 loop: GET /Items?userId={USER_ID}&parentId={VIEW_ID}&recursive=true &includeItemTypes=Movie&sortBy=SortName&sortOrder=Ascending &startIndex={start}&limit={PAGE} → {Items: [...], TotalRecordCount: N, StartIndex: start} emit .Items; start += len(Items) stop when len(Items) == 0 OR start >= TotalRecordCount ``` Why `/UserViews` and not `/Library/MediaFolders`: MediaFolders is admin-elevated (RequiresElevation) — a non-admin token gets 403. UserViews is the per-user library list for any token. ```bash scripts/jellyfin libraries --json | jq -r '.libraries[] | [.name, .id] | @tsv' scripts/jellyfin browse --library-id "$VIEW_ID" --type Movie --limit 100 --start-index 0 --json \ | jq -r '.items[] | [.name, .year] | @tsv' ``` ## Recipe 3 — Search → seasons → episodes walk ``` 1. FIND SERIES: GET /Search/Hints?searchTerm=breaking&includeItemTypes=Series&limit=20&userId={USER_ID} → {SearchHints: [{Id (read .Id; .ItemId is the deprecated twin), Name, Type, ...}]} Fuller DTOs alternative: GET /Items?userId=..&recursive=true&includeItemTypes=Series&searchTerm=..&fields=Overview 2. SEASONS: GET /Shows/{SERIES_ID}/Seasons?userId={USER_ID}&fields=Overview → {Items: [{Id, Name, IndexNumber (0 = specials), Type: "Season"}]} 3. EPISODES per season (prefer seasonId over numeric season — numbers shift): GET /Shows/{seriesId}/Episodes?userId={USER_ID}&seasonId={SEASON_ID}&sortBy=AiredEpisodeOrder → {Items: [{Name, IndexNumber, ParentIndexNumber, UserData.PlayedPercentage, RunTimeTicks}]} 4. Whole-series flat option (skip seasons): GET /Shows/{seriesId}/Episodes?userId={USER_ID}&startIndex=0&limit=100 5. NEXT UP for one series: GET /Shows/NextUp?userId={USER_ID}&seriesId={SERIES_ID}&limit=10 ``` ```bash scripts/jellyfin search --query "breaking bad" --type Series --json | jq -r '.results[0].id' scripts/jellyfin item --id "$SERIES_ID" --user-id "$USER_ID" --json scripts/jellyfin next-up --user-id "$USER_ID" --limit 10 --json | jq -r '.items[] | .series' ``` ## Recipe 4 — server health → stats → next-up evening plan ``` 1. GET /System/Info/Public # no auth: is the server up? which version? 2. GET /System/Info # token: OperatingSystem, Version (full) 3. GET /Items/Counts?userId={USER_ID} # MovieCount, SeriesCount, EpisodeCount, SongCount 4. GET /Shows/NextUp?userId={USER_ID}&limit=5 # always pass userId (NextUp ≤10.8 crashes without) → {Items: [Episode...], TotalRecordCount} ``` ```bash scripts/jellyfin info --json scripts/jellyfin stats --json | jq -r '.movies, .episodes' scripts/jellyfin next-up --limit 5 --json ``` ## Recipe 5 — find an item id → full details → image URL ``` 1. scripts/jellyfin search --query "dune" --type Movie --json → .results[0].id 2. GET /Items/{ITEM_ID}?userId={USER_ID} → BaseItemDto: Overview, Genres, CommunityRating, OfficialRating, RunTimeTicks, ProductionYear, ImageTags.Primary, BackdropImageTags[], ProviderIds 3. Image URL: {BASE}/Items/{ITEM_ID}/Images/Primary?maxWidth=300&tag={ImageTags.Primary} # 404 here means "no such image" — fall back to Thumb/Backdrop, not an error ``` `RunTimeTicks` are 100-nanosecond ticks (divide by 600,000,000 for minutes). ## Bundled CLI `--dry-run` and exit-code contract The CLI's dry-run plans are pinned by its offline test suite (`scripts/test_jellyfin_cli.py`), so jq keys match tested reality exactly. Every plan carries: ```json { "dry_run": true, "path": "/Items/Latest", "params": { "userId": null, "limit": 10 } } ``` - `dry_run` (bool, always true), `path` (string) and `params` (object) appear on every command plan; `login` instead emits `path: "/Users/AuthenticateByName"`, `server`, `username`, `authorization_header`, and `pre_token_header: true` (its `authorization_header` is the complete pre-token MediaBrowser header, no `Token=` segment); `info` composes a `requests` array of `{path, params}` steps instead of a single `path`/`params` pair. - `params` mirrors the exact query the live call would send (`userId` is JSON `null` when not supplied). Exit codes: `0` on success (including dry-run previews), `1` on CLI errors (missing credentials, unreachable server, API 4xx/5xx, missing required `--user-id`), `2` on argparse misuse such as `--movies --episodes` together or a missing required flag. ## Cross-version-safe baseline (derive your own recipes from these rules) 1. Speak modern auth (`Authorization: MediaBrowser ...`) and put the token in exactly ONE channel per request (its `Token=` parameter). Never co-send the legacy `X-Emby-Token` header with it — precedence across channels is unspecified; if a legacy-only server needs the old header, substitute it there, do not stack channels. 2. Send a complete pre-token header everywhere — zero cost, avoids the 400-on-login trap. 3. Always send explicit `userId` on `/Items*`, `/UserViews`, `/Shows/*`, `/Items/Latest`. 4. Resolve identity once: `USER_ID = AuthenticationResult.User.Id` (fallback `/Users/Me` with a user token; NEVER with an API key). 5. Honor 503 + `Retry-After` on any endpoint; never retry 400-class. 6. Probe `GET /System/Info/Public` first; branch on semver (NextUp userId <10.9; legacy header availability ≥10.11 config). 7. Branch on shape: wrapper object vs bare array (`/Items/Latest`) vs `SearchHints` key. 8. DeviceId per profile — one token per `(DeviceId, user)` pair; re-login revokes the pair's previous token. ## Sources Endpoint semantics, pagination, and auth sequencing inherit citations from the auth, endpoint, and gotchas references (all fetched this session): https://api.jellyfin.org/ · https://api.jellyfin.org/openapi/jellyfin-openapi-stable.json · https://gist.github.com/nielsvanvelzen/ea047d9028f676185832e51ffaf12a6f · https://mintlify.wiki/jellyfin/jellyfin/api/authentication/overview · https://kotlin-sdk.jellyfin.org/guide/authentication.html · https://github.com/jellyfin/jellyfin (ItemsController.cs, TvShowsController.cs, UserViewsController.cs, LibraryController.cs, SessionManager.cs)
-
-
scripts
-
jellyfin 34 KB · in bundle
-
test_jellyfin_cli.py 37.2 KB
import contextlib import importlib.machinery import importlib.util import io import json import os import pathlib import subprocess import tempfile import unittest from unittest.mock import Mock, patch SCRIPT = pathlib.Path(__file__).resolve().parent / "jellyfin" LOADER = importlib.machinery.SourceFileLoader("jellyfin_cli", str(SCRIPT)) SPEC = importlib.util.spec_from_loader(LOADER.name, LOADER) jellyfin_cli = importlib.util.module_from_spec(SPEC) LOADER.exec_module(jellyfin_cli) def clean_env(): env = os.environ.copy() for var in ("JELLYFIN_URL", "JELLYFIN_API_KEY", "JELLYFIN_TOKEN", "JELLYFIN_USER_ID", "JELLYFIN_DEVICE_ID", "JELLYFIN_PASSWORD"): env.pop(var, None) return env class FakeResponse: def __init__(self, status_code=200, json_body=None, text="", headers=None): self.status_code = status_code self._json = json_body self.text = text or (json.dumps(json_body) if json_body is not None else "") self.headers = headers or {} def json(self): if self._json is None: raise ValueError("no json") return self._json class FakeClient: def __init__(self, libraries=None, dry_run=False): self.dry_run = dry_run self.libraries = libraries self.recent_calls = [] def get_libraries(self): return self.libraries def get_recent(self, user_id, limit=10, include_types=None): self.recent_calls.append((user_id, limit, include_types)) return [{"Name": "Arrival", "Type": "Movie", "Id": "movie-1"}] class NavigationFakeClient: def __init__(self, dry_run=False): self.dry_run = dry_run self.next_up_calls = [] self.item_calls = [] self.items_calls = [] self.seasons_calls = [] self.episodes_calls = [] def get_next_up(self, user_id, limit=10, series_id=None): self.next_up_calls.append((user_id, limit, series_id)) return { "Items": [{"Name": "The Signal", "Type": "Episode", "Id": "episode-1", "SeriesName": "Voyagers", "IndexNumber": 4}], "StartIndex": 0, "TotalRecordCount": 9, } def get_item(self, item_id, user_id): self.item_calls.append((item_id, user_id)) return {"Name": "The Signal", "Type": "Episode", "Id": item_id, "SeriesName": "Voyagers", "IndexNumber": 4, "Overview": "A message arrives."} def get_items(self, parent_id, types=None, limit=50, sort_by="SortName", sort_order="Ascending", start_index=0, user_id=None): self.items_calls.append((parent_id, types, limit, sort_by, sort_order, start_index, user_id)) return { "Items": [{"Name": "Arrival", "Type": "Movie", "Id": "movie-1", "ProductionYear": 2016}], "StartIndex": start_index, "TotalRecordCount": 1, } def get_seasons(self, series_id, user_id): self.seasons_calls.append((series_id, user_id)) return {"Items": [{"Name": "Season 1", "Type": "Season", "Id": "season-1", "ParentIndexNumber": 1}], "TotalRecordCount": 1} def get_episodes(self, series_id, season_id, user_id): self.episodes_calls.append((series_id, season_id, user_id)) return {"Items": [{"Name": "The Signal", "Type": "Episode", "Id": "episode-1", "ParentIndexNumber": 1, "IndexNumber": 4}], "TotalRecordCount": 1} class JellyfinCliTests(unittest.TestCase): def setUp(self): self.flags = jellyfin_cli.GLOBAL_FLAGS self.env_user_id = jellyfin_cli.ENV_USER_ID jellyfin_cli.GLOBAL_FLAGS = {"json": True, "dry_run": False} def tearDown(self): jellyfin_cli.GLOBAL_FLAGS = self.flags jellyfin_cli.ENV_USER_ID = self.env_user_id def test_hardened_recent_and_libraries_contracts(self): output = io.StringIO() libraries = FakeClient(libraries={"Items": [{"Name": "Films", "Id": "lib-1", "CollectionType": "movies"}]}) with contextlib.redirect_stdout(output): jellyfin_cli.cmd_libraries(libraries, []) self.assertEqual(json.loads(output.getvalue())["libraries"][0]["name"], "Films") calls = [] client = jellyfin_cli.JellyfinClient() client._get = lambda path, params=None: calls.append((path, params)) or [] client.get_recent("user-1", limit=3, include_types=["Movie", "Episode"]) self.assertEqual(calls, [("/Items/Latest", {"userId": "user-1", "includeItemTypes": "Movie,Episode", "limit": 3, "fields": "DateCreated"})]) recent = FakeClient() with contextlib.redirect_stdout(io.StringIO()): jellyfin_cli.cmd_recent(recent, ["--user-id", "user-1", "--movies", "--limit", "2"]) self.assertEqual(recent.recent_calls, [("user-1", 2, ["Movie"])]) with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): jellyfin_cli.cmd_recent(recent, ["--movies", "--episodes"]) result = subprocess.run( [str(SCRIPT), "recent", "--movies", "--episodes"], capture_output=True, text=True, ) self.assertEqual(result.returncode, 2) self.assertIn("not allowed with argument", result.stderr) def test_recent_requires_user_before_network_and_dry_run_previews_request(self): jellyfin_cli.ENV_USER_ID = "" client = FakeClient() error = io.StringIO() with contextlib.redirect_stderr(error), self.assertRaises(SystemExit): jellyfin_cli.cmd_recent(client, []) self.assertIn("JELLYFIN_USER_ID", error.getvalue()) self.assertEqual(client.recent_calls, []) dry_run = FakeClient(dry_run=True) output = io.StringIO() with contextlib.redirect_stdout(output): jellyfin_cli.cmd_recent(dry_run, ["--movies", "--limit", "2"]) self.assertEqual(json.loads(output.getvalue()), { "dry_run": True, "path": "/Items/Latest", "params": {"userId": None, "includeItemTypes": "Movie", "limit": 2, "fields": "DateCreated"}, }) self.assertEqual(dry_run.recent_calls, []) def test_navigation_client_contracts(self): calls = [] client = jellyfin_cli.JellyfinClient() client._get = lambda path, params=None: calls.append((path, params)) or {} client.get_next_up("user-1", limit=3, series_id="series-1") client.get_item("item-1", "user-1") client.get_items("library-1", types=["Movie", "Series"], limit=4, start_index=2, user_id="user-1") self.assertEqual(calls, [ ("/Shows/NextUp", {"userId": "user-1", "limit": 3, "seriesId": "series-1"}), ("/Items/item-1", {"userId": "user-1"}), ("/Items", {"parentId": "library-1", "limit": 4, "sortBy": "SortName", "sortOrder": "Ascending", "startIndex": 2, "recursive": True, "userId": "user-1", "includeItemTypes": "Movie,Series"}), ]) def test_authorization_header_builds_media_browser_scheme(self): header = jellyfin_cli.build_authorization_header("dev-42") self.assertTrue(header.startswith("MediaBrowser ")) self.assertIn('Client="jellyfin-cli"', header) self.assertIn('DeviceId="dev-42"', header) for required in ("Client=", "Device=", "DeviceId=", "Version="): self.assertIn(required, header) self.assertNotIn("Token=", header) # pre-token form carries no token segment with_token = jellyfin_cli.build_authorization_header("dev-42", token="tok-1") self.assertIn('Token="tok-1"', with_token) def test_next_up_item_and_browse_parse_results_as_json(self): client = NavigationFakeClient() output = io.StringIO() with contextlib.redirect_stdout(output): jellyfin_cli.cmd_next_up(client, ["--user-id", "user-1", "--limit", "3"]) self.assertEqual(json.loads(output.getvalue()), { "items": [{"id": "episode-1", "name": "The Signal", "type": "Episode", "series": "Voyagers", "episode_number": 4}], "total_record_count": 9, }) self.assertEqual(client.next_up_calls, [("user-1", 3, None)]) output = io.StringIO() with contextlib.redirect_stdout(output): jellyfin_cli.cmd_item(client, ["--id", "episode-1", "--user-id", "user-1"]) self.assertEqual(json.loads(output.getvalue()), { "id": "episode-1", "name": "The Signal", "type": "Episode", "series": "Voyagers", "episode_number": 4, "overview": "A message arrives.", }) self.assertEqual(client.item_calls, [("episode-1", "user-1")]) output = io.StringIO() with contextlib.redirect_stdout(output): jellyfin_cli.cmd_browse(client, ["--library-id", "library-1", "--type", "Movie", "--limit", "4", "--start-index", "2", "--user-id", "user-1"]) self.assertEqual(json.loads(output.getvalue()), { "items": [{"id": "movie-1", "name": "Arrival", "type": "Movie", "year": 2016}], "start_index": 2, "total_record_count": 1, }) self.assertEqual(client.items_calls, [("library-1", ["Movie"], 4, "SortName", "Ascending", 2, "user-1")]) def test_user_scoped_navigation_requires_user_before_network(self): jellyfin_cli.ENV_USER_ID = "" for handler, arguments in ( (jellyfin_cli.cmd_next_up, []), (jellyfin_cli.cmd_item, ["--id", "item-1"]), ): client = NavigationFakeClient() with self.subTest(handler=handler.__name__), contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): handler(client, arguments) self.assertEqual(client.next_up_calls, []) self.assertEqual(client.item_calls, []) def test_navigation_dry_runs_do_not_call_network_and_emit_requests(self): cases = ( (jellyfin_cli.cmd_next_up, ["--limit", "3"], {"path": "/Shows/NextUp", "params": {"userId": None, "limit": 3}}), (jellyfin_cli.cmd_next_up, ["--limit", "3", "--series-id", "s1"], {"path": "/Shows/NextUp", "params": {"userId": None, "limit": 3, "seriesId": "s1"}}), (jellyfin_cli.cmd_item, ["--id", "item-1"], {"path": "/Items/item-1", "params": {"userId": None}}), (jellyfin_cli.cmd_browse, ["--library-id", "library-1", "--type", "Movie,Series", "--limit", "4", "--start-index", "2"], {"path": "/Items", "params": {"parentId": "library-1", "limit": 4, "sortBy": "SortName", "sortOrder": "Ascending", "startIndex": 2, "recursive": True, "includeItemTypes": "Movie,Series"}}), ) for handler, arguments, request in cases: client = NavigationFakeClient(dry_run=True) output = io.StringIO() with self.subTest(handler=handler.__name__), contextlib.redirect_stdout(output): handler(client, arguments) self.assertEqual(json.loads(output.getvalue()), {"dry_run": True, **request}) self.assertEqual(client.next_up_calls, []) self.assertEqual(client.item_calls, []) self.assertEqual(client.items_calls, []) def test_navigation_commands_dispatch_and_leaf_help_has_examples(self): for command, arguments in ( ("next-up", ["--user-id", "user-1", "--limit", "2"]), ("item", ["--id", "item-1", "--user-id", "user-1"]), ("browse", ["--library-id", "library-1", "--limit", "2"]), ): result = subprocess.run([str(SCRIPT), "--json", "--dry-run", command, *arguments], capture_output=True, text=True) with self.subTest(command=command): self.assertEqual(result.returncode, 0, result.stderr) self.assertTrue(json.loads(result.stdout)["dry_run"]) help_result = subprocess.run([str(SCRIPT), command, "--help"], capture_output=True, text=True) with self.subTest(help_command=command): self.assertEqual(help_result.returncode, 0) self.assertIn("Example:", help_result.stdout) class LoginAuthSequenceTests(unittest.TestCase): """The login path must demonstrate the researched auth sequence: complete pre-token MediaBrowser header on POST /Users/AuthenticateByName, AccessToken returned, Token= header for everything after.""" def run_cli(self, *args): return subprocess.run([str(SCRIPT), *args], text=True, capture_output=True, env=clean_env(), cwd=tempfile.gettempdir()) def test_help_lists_login_and_every_subcommand(self): result = self.run_cli("--help") self.assertEqual(result.returncode, 0) for noun in ("login", "info", "recent", "search", "next-up", "item", "seasons", "episodes", "browse", "libraries", "stats"): self.assertIn(noun, result.stdout) def test_login_requires_username(self): result = self.run_cli("login") self.assertNotEqual(result.returncode, 0) self.assertIn("--username", result.stderr) self.assertNotIn("Traceback", result.stderr) def test_login_dry_run_previews_pre_token_header_without_network(self): result = self.run_cli("--dry-run", "--json", "login", "--username", "alice") self.assertEqual(result.returncode, 0, result.stderr) payload = json.loads(result.stdout) self.assertTrue(payload["dry_run"]) self.assertEqual(payload["path"], "/Users/AuthenticateByName") header = payload["authorization_header"] self.assertIn("MediaBrowser", header) for part in ("Client=", "Device=", "DeviceId=", "Version="): self.assertIn(part, header) self.assertNotIn("Token=", header) # pre-token: no token exists yet self.assertTrue(payload["pre_token_header"]) def test_login_strips_trailing_slash_from_server_override(self): result = self.run_cli("--dry-run", "--json", "login", "--username", "bob", "--server", "http://box.local:8096/") payload = json.loads(result.stdout) self.assertEqual(payload["server"], "http://box.local:8096") def test_mocked_login_sends_media_browser_header_and_returns_session(self): cli = jellyfin_cli cli.GLOBAL_FLAGS = {"json": True, "dry_run": False} captured = {} response = FakeResponse(200, json_body={ "User": {"Name": "alice", "Id": "6eec632a-ff0d-4d09-aad0-bf9e90b14bc6"}, "SessionInfo": {"UserId": "6eec632a-ff0d-4d09-aad0-bf9e90b14bc6"}, "AccessToken": "at-1234", "ServerId": "srv-1", }) with patch.object(cli.requests, "post") as poster: poster.return_value = response output = io.StringIO() with contextlib.redirect_stdout(output): cli.cmd_login(cli.JellyfinClient(url="http://s:8096", device_id="dev-9"), ["--username", "alice", "--password", "pw"]) captured["call"] = poster.call_args cli.GLOBAL_FLAGS = {"json": False, "dry_run": False} args, kwargs = captured["call"] self.assertTrue(args[0].endswith("/Users/AuthenticateByName")) header = kwargs["headers"]["Authorization"] self.assertIn("MediaBrowser", header) self.assertIn('DeviceId="dev-9"', header) self.assertNotIn("Token=", header) # pre-token header on the login call itself self.assertEqual(kwargs["json"], {"Username": "alice", "Pw": "pw"}) session = json.loads(output.getvalue()) self.assertEqual(session["user_id"], "6eec632a-ff0d-4d09-aad0-bf9e90b14bc6") self.assertEqual(session["access_token"], "at-1234") self.assertIn('Token="at-1234"', session["authorization_header"]) def test_mocked_login_error_paths_do_not_crash(self): cli = jellyfin_cli cli.GLOBAL_FLAGS = {"json": False, "dry_run": False} for status, expected_fragment in ((400, "400"), (401, "401"), (403, "403")): with self.subTest(status=status): response = FakeResponse(status, text="Error processing request.") with patch.object(cli.requests, "post", return_value=response), \ contextlib.redirect_stderr(io.StringIO()) as stderr, \ self.assertRaises(SystemExit): cli.cmd_login(cli.JellyfinClient(url="http://s:8096", device_id="d"), ["--username", "alice", "--password", "bad"]) self.assertIn(expected_fragment, stderr.getvalue()) cli.GLOBAL_FLAGS = {"json": False, "dry_run": False} def test_reads_require_a_credential_before_network(self): cli = jellyfin_cli client = cli.JellyfinClient() client.key = "" client.token = "" error = io.StringIO() with contextlib.redirect_stderr(error), self.assertRaises(SystemExit): client._get("/System/Info") self.assertIn("JELLYFIN_API_KEY", error.getvalue()) def test_client_headers_use_modern_authorization_scheme(self): cli = jellyfin_cli client = cli.JellyfinClient(key="k-1", device_id="dev-1") headers = client._headers() self.assertIn('Token="k-1"', headers["Authorization"]) self.assertTrue(headers["Authorization"].startswith("MediaBrowser ")) self.assertNotIn("X-Emby-Token", headers) # one token channel per request token_client = cli.JellyfinClient(token="t-1", device_id="dev-1") token_headers = token_client._headers() self.assertIn('Token="t-1"', token_headers["Authorization"]) self.assertNotIn("X-Emby-Token", token_headers) def test_captured_requests_carry_token_in_exactly_one_channel(self): """VAL-JF-009: the access token (or API key) appears in exactly ONE channel per request — the MediaBrowser Token= parameter — and the legacy X-Emby-Token header is never sent alongside it.""" cli = jellyfin_cli captured = [] response = FakeResponse(200, json_body={"Items": [], "TotalRecordCount": 0}) for client in (cli.JellyfinClient(key="k-capture", device_id="dev-cap"), cli.JellyfinClient(token="t-capture", device_id="dev-cap")): with patch.object(cli.requests, "get", side_effect=lambda url, **kw: captured.append(kw) or response): client._get("/Items") self.assertEqual(len(captured), 2) for kwargs in captured: self.assertIn("headers", kwargs) headers = kwargs["headers"] self.assertIn("Token=", headers["Authorization"]) self.assertNotIn("X-Emby-Token", headers) channel_count = sum( 1 for value in headers.values() if "Token=" in value or value.lower() == "x-emby-token" ) self.assertEqual(channel_count, 1) class TvNavigationCommandTests(unittest.TestCase): """seasons/episodes commands close the search → seasons → episodes walk.""" def setUp(self): self.flags = jellyfin_cli.GLOBAL_FLAGS self.env_user_id = jellyfin_cli.ENV_USER_ID jellyfin_cli.GLOBAL_FLAGS = {"json": True, "dry_run": False} jellyfin_cli.ENV_USER_ID = "" def tearDown(self): jellyfin_cli.GLOBAL_FLAGS = self.flags jellyfin_cli.ENV_USER_ID = self.env_user_id def test_seasons_and_episodes_emit_items_and_consume_ids(self): client = NavigationFakeClient() output = io.StringIO() with contextlib.redirect_stdout(output): jellyfin_cli.cmd_seasons(client, ["--series-id", "series-1", "--user-id", "user-1"]) seasons = json.loads(output.getvalue()) self.assertEqual(seasons["items"][0]["name"], "Season 1") self.assertEqual(seasons["items"][0]["season_number"], 1) self.assertEqual(client.seasons_calls, [("series-1", "user-1")]) output = io.StringIO() with contextlib.redirect_stdout(output): jellyfin_cli.cmd_episodes(client, ["--series-id", "series-1", "--season-id", "season-1", "--user-id", "user-1"]) episodes = json.loads(output.getvalue()) self.assertEqual(episodes["items"][0]["episode_number"], 4) self.assertEqual(client.episodes_calls, [("series-1", "season-1", "user-1")]) def test_seasons_and_episodes_require_user_before_network(self): for handler, arguments in ( (jellyfin_cli.cmd_seasons, ["--series-id", "series-1"]), (jellyfin_cli.cmd_episodes, ["--series-id", "series-1"]), ): client = NavigationFakeClient() with self.subTest(handler=handler.__name__), \ contextlib.redirect_stderr(io.StringIO()), \ self.assertRaises(SystemExit): handler(client, arguments) self.assertEqual(client.seasons_calls, []) self.assertEqual(client.episodes_calls, []) def test_seasons_and_episodes_dry_run_previews_requests(self): cases = ( (jellyfin_cli.cmd_seasons, ["--series-id", "s1"], {"path": "/Shows/s1/Seasons", "params": {"userId": None}}), (jellyfin_cli.cmd_episodes, ["--series-id", "s1", "--season-id", "se1", "--limit", "7"], {"path": "/Shows/s1/Episodes", "params": {"userId": None, "limit": 7, "startIndex": 0, "seasonId": "se1"}}), ) for handler, arguments, request in cases: client = NavigationFakeClient(dry_run=True) output = io.StringIO() with self.subTest(handler=handler.__name__), \ contextlib.redirect_stdout(output): handler(client, arguments) self.assertEqual(json.loads(output.getvalue()), {"dry_run": True, **request}) self.assertEqual(client.seasons_calls, []) self.assertEqual(client.episodes_calls, []) class SearchHintIdFallbackTests(unittest.TestCase): """SearchHint carries both Id and (deprecated) ItemId; newer servers omit ItemId.""" def test_prefers_current_id_falls_back_to_deprecated_item_id(self): jellyfin_cli.GLOBAL_FLAGS = {"json": True, "dry_run": False} try: client = Mock() client.dry_run = False client.search = Mock(return_value={ "SearchHints": [ {"Id": "new-id", "ItemId": "legacy-id", "Name": "Both", "Type": "Series"}, {"Id": "only-new", "Name": "Modern", "Type": "Movie"}, {"ItemId": "legacy-only", "Name": "Old server", "Type": "Movie"}, ], "TotalRecordCount": 3, }) output = io.StringIO() with contextlib.redirect_stdout(output): jellyfin_cli.cmd_search(client, ["--query", "dune"]) results = json.loads(output.getvalue())["results"] self.assertEqual([r["id"] for r in results], ["new-id", "only-new", "legacy-only"]) finally: jellyfin_cli.GLOBAL_FLAGS = {"json": False, "dry_run": False} class SearchRequiresQueryTests(unittest.TestCase): def test_missing_query_is_argument_error(self): result = subprocess.run([str(SCRIPT), "search"], capture_output=True, text=True, env=clean_env()) self.assertNotEqual(result.returncode, 0) self.assertIn("--query", result.stderr) self.assertNotIn("Traceback", result.stderr) class PipelineChainTests(unittest.TestCase): """Documented multi-step pipelines must execute stage by stage, each stage consuming the previous stage's emitted output (field names AND types).""" @classmethod def setUpClass(cls): cls.tmpdir = tempfile.TemporaryDirectory(prefix="jellyfin-pipeline-") @classmethod def tearDownClass(cls): cls.tmpdir.cleanup() def run_cli(self, *args): return subprocess.run([str(SCRIPT), "--dry-run", "--json", *args], text=True, capture_output=True, env=clean_env(), cwd=self.tmpdir.name) def run_jq(self, *jq_args): return subprocess.run(["jq", *jq_args], text=True, capture_output=True, env=clean_env(), cwd=self.tmpdir.name) def write_stage_file(self, name, document): path = pathlib.Path(self.tmpdir.name) / name path.write_text(json.dumps(document)) return str(path) def test_search_then_item_chain_consumability(self): # Stage 1: search plan; jq extracts the query field (string) the next # stage would resolve into an item id. r1 = self.run_cli("search", "--query", "dune", "--type", "Movie") self.assertEqual(r1.returncode, 0, r1.stderr) stage1 = self.write_stage_file("stage1.json", json.loads(r1.stdout)) term = self.run_jq("-r", ".params.searchTerm", stage1).stdout.strip() self.assertEqual(term, "dune") types_check = self.run_jq("-r", ".params.includeItemTypes | type", stage1) self.assertEqual(types_check.stdout.strip(), "string") # Stage 2: item detail plan consumes a hand-built id from stage 1's # contract (results[].id is a string); jq proves the id travels into # the request path. r2 = self.run_cli("item", "--id", "movie-123", "--user-id", "user-9") self.assertEqual(r2.returncode, 0, r2.stderr) stage2 = self.write_stage_file("stage2.json", json.loads(r2.stdout)) item_path = self.run_jq("-r", ".path", stage2).stdout.strip() self.assertEqual(item_path, "/Items/movie-123") user_id = self.run_jq("-r", ".params.userId", stage2).stdout.strip() self.assertEqual(user_id, "user-9") # Stage 3: next-up plan consumes the same user id and proves it is a # JSON string parameter on /Shows/NextUp. r3 = self.run_cli("next-up", "--user-id", "user-9", "--limit", "5") self.assertEqual(r3.returncode, 0, r3.stderr) stage3 = self.write_stage_file("stage3.json", json.loads(r3.stdout)) self.assertEqual(self.run_jq("-r", ".params.userId", stage3).stdout.strip(), "user-9") self.assertEqual(self.run_jq("-r", ".path", stage3).stdout.strip(), "/Shows/NextUp") def test_libraries_then_browse_chain_consumability(self): # Stage 1: libraries plan; jq type-checks the id field browse consumes. r1 = self.run_cli("libraries") self.assertEqual(r1.returncode, 0, r1.stderr) stage1 = self.write_stage_file("stage1.json", json.loads(r1.stdout)) self.assertEqual(self.run_jq("-r", ".path", stage1).stdout.strip(), "/Library/MediaFolders") # Stage 2: browse plan consumes a library id and paginates by # startIndex (number type asserted via jq). r2 = self.run_cli("browse", "--library-id", "lib-77", "--start-index", "100") self.assertEqual(r2.returncode, 0, r2.stderr) stage2 = self.write_stage_file("stage2.json", json.loads(r2.stdout)) parent = self.run_jq("-r", ".params.parentId", stage2).stdout.strip() self.assertEqual(parent, "lib-77") start_index_type = self.run_jq("-r", ".params.startIndex | type", stage2) self.assertEqual(start_index_type.stdout.strip(), "number") # Stage 3: seasons plan consumes a series id discovered by browsing. r3 = self.run_cli("seasons", "--series-id", "series-2", "--user-id", "user-1") self.assertEqual(r3.returncode, 0, r3.stderr) stage3 = self.write_stage_file("stage3.json", json.loads(r3.stdout)) self.assertEqual(self.run_jq("-r", ".path", stage3).stdout.strip(), "/Shows/series-2/Seasons") def test_login_to_recent_chain_previews_token_handoff(self): # Stage 1: login plan emits the pre-token header the server requires. r1 = self.run_cli("login", "--username", "alice") self.assertEqual(r1.returncode, 0, r1.stderr) stage1 = self.write_stage_file("stage1.json", json.loads(r1.stdout)) header = self.run_jq("-r", ".authorization_header", stage1).stdout.strip() self.assertIn("MediaBrowser", header) self.assertNotIn("Token=", header) # Stage 2: user-scoped read plan; the user id login would capture is a # string parameter on /Items/Latest (shape: bare array per research). r2 = self.run_cli("recent", "--user-id", "6eec632a", "--limit", "20") self.assertEqual(r2.returncode, 0, r2.stderr) stage2 = self.write_stage_file("stage2.json", json.loads(r2.stdout)) self.assertEqual(self.run_jq("-r", ".path", stage2).stdout.strip(), "/Items/Latest") user_id_type = self.run_jq("-r", ".params.userId | type", stage2) self.assertEqual(user_id_type.stdout.strip(), "string") self.assertEqual(self.run_jq("-r", ".params.fields", stage2).stdout.strip(), "DateCreated") class DispatchHardeningTests(unittest.TestCase): """main() must dispatch on the first UNCONSUMED subcommand token. A value-flag pair whose value NAMES a subcommand while sitting before the real command (e.g. `--server search browse ...`) used to make argparse dispatch the wrong subparser (the token was swallowed as the flag's value and the real command shifted into the value slot). The hardened dispatch lifts such pairs out of the top-level argv and re-attaches them to the command tail, where parse_known_args already tolerates unknown flags. Every other pre-command token still reaches argparse so its errors are byte-identical to the pre-hardening CLI. """ def run_cli(self, *args): return subprocess.run([str(SCRIPT), *args], text=True, capture_output=True, env=clean_env(), cwd=tempfile.gettempdir()) def test_flag_value_equal_to_subcommand_dispatches_browse_not_search(self): # The exact mis-slice scenario: `--server search` must not dispatch # the `search` sub-parser; the real command is `browse`. result = self.run_cli("--server", "search", "browse", "--library-id", "lib-1", "--dry-run", "--json") self.assertEqual(result.returncode, 0, result.stderr) payload = json.loads(result.stdout) self.assertEqual(payload["path"], "/Items") self.assertEqual(payload["params"]["parentId"], "lib-1") def test_value_hijack_variants_dispatch_the_real_command(self): cases = ( (("recent", "--user-id", "u1", "--limit", "3"), "/Items/Latest"), (("search", "--query", "dune"), "/Search/Hints"), (("next-up", "--user-id", "u1"), "/Shows/NextUp"), (("item", "--id", "i1", "--user-id", "u1"), "/Items/i1"), (("seasons", "--series-id", "s1", "--user-id", "u1"), "/Shows/s1/Seasons"), (("episodes", "--series-id", "s1", "--user-id", "u1"), "/Shows/s1/Episodes"), (("libraries",), "/Library/MediaFolders"), (("stats",), "/Items/Counts"), ) for command, expected_path in cases: with self.subTest(command=command): result = self.run_cli("--server", "search", *command, "--dry-run", "--json") self.assertEqual(result.returncode, 0, result.stderr) payload = json.loads(result.stdout) self.assertEqual(payload["path"], expected_path) def test_hijack_variants_cover_flag_command_and_no_flag_value_commands(self): # info emits a `requests` array instead of a path/params pair. result = self.run_cli("--server", "search", "info", "--dry-run", "--json") self.assertEqual(result.returncode, 0, result.stderr) payload = json.loads(result.stdout) self.assertEqual(payload["requests"][0]["path"], "/System/Info") # login keeps its plan shape; the misplaced pair rides along as the # server value rather than being dropped. result = self.run_cli("--server", "search", "login", "--username", "alice", "--dry-run", "--json") self.assertEqual(result.returncode, 0, result.stderr) payload = json.loads(result.stdout) self.assertEqual(payload["path"], "/Users/AuthenticateByName") self.assertEqual(payload["server"], "search") self.assertTrue(payload["pre_token_header"]) def test_all_subcommands_dispatch_from_clean_argv(self): cases = ( (("login", "--username", "alice"), "/Users/AuthenticateByName"), (("info",), "/System/Info"), (("recent", "--user-id", "u1"), "/Items/Latest"), (("search", "--query", "dune"), "/Search/Hints"), (("next-up", "--user-id", "u1"), "/Shows/NextUp"), (("item", "--id", "i1", "--user-id", "u1"), "/Items/i1"), (("seasons", "--series-id", "s1", "--user-id", "u1"), "/Shows/s1/Seasons"), (("episodes", "--series-id", "s1", "--user-id", "u1"), "/Shows/s1/Episodes"), (("browse", "--library-id", "lib-1"), "/Items"), (("libraries",), "/Library/MediaFolders"), (("stats",), "/Items/Counts"), ) for command, expected_path in cases: with self.subTest(command=command[0]): result = self.run_cli(*command, "--dry-run", "--json") self.assertEqual(result.returncode, 0, result.stderr) payload = json.loads(result.stdout) if "requests" in payload: # info composes a request array self.assertEqual(payload["requests"][0]["path"], expected_path) else: self.assertEqual(payload["path"], expected_path) def test_properly_placed_flag_value_still_wins_over_misplaced_pair(self): result = self.run_cli("--server", "search", "login", "--server", "http://real:8096", "--username", "alice", "--dry-run", "--json") self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(json.loads(result.stdout)["server"], "http://real:8096") def test_pre_command_tokens_argparse_owns_are_unchanged(self): # Unknown flags, stray positionals, a non-subcommand --server value, # a dangling value flag, and `--` all keep their pre-hardening # argparse errors (exit 2, no traceback, no tolerant dispatch). cases = ( ("--bogus", "info"), ("junk", "browse", "--library-id", "lib-1"), ("--server", "http://x:8096", "info"), ("--server",), ("--", "search", "--query", "dune"), ) for argv in cases: with self.subTest(argv=argv): result = self.run_cli(*argv, "--dry-run", "--json") self.assertEqual(result.returncode, 2) self.assertIn("error:", result.stderr) self.assertNotIn("Traceback", result.stderr) def test_find_subcommand_token_returns_command_and_pair_indices(self): cli = jellyfin_cli subs = {"login", "info", "recent", "search", "next-up", "item", "seasons", "episodes", "browse", "libraries", "stats"} cases = ( (["jf", "--server", "search", "browse", "--library-id", "L"], (3, 1)), (["jf", "browse", "--library-id", "L"], (1, None)), (["jf", "--server", "http://x", "info"], (3, None)), (["jf", "login", "--server", "search"], (1, None)), (["jf", "--bogus", "info"], (None, None)), (["jf", "--server"], (None, None)), (["jf", "--"], (None, None)), ) for argv, expected in cases: with self.subTest(argv=argv): self.assertEqual( cli.find_subcommand_token(argv, subs, cli.VALUE_FLAGS), expected) def test_split_misplaced_value_pairs_lifts_only_hijacking_pair(self): cli = jellyfin_cli subs = {"login", "info", "recent", "search", "next-up", "item", "seasons", "episodes", "browse", "libraries", "stats"} parse_argv, misplaced = cli.split_misplaced_value_pairs( ["jf", "--server", "search", "browse", "--library-id", "L"], subs, cli.VALUE_FLAGS) self.assertEqual(parse_argv, ["jf", "browse", "--library-id", "L"]) self.assertEqual(misplaced, ["--server", "search"]) # A value that is not a subcommand name never lifts anything, and # clean argv passes through untouched. parse_argv, misplaced = cli.split_misplaced_value_pairs( ["jf", "--server", "http://x", "info"], subs, cli.VALUE_FLAGS) self.assertEqual((parse_argv, misplaced), (["jf", "--server", "http://x", "info"], [])) parse_argv, misplaced = cli.split_misplaced_value_pairs( ["jf", "login", "--server", "search", "--username", "a"], subs, cli.VALUE_FLAGS) self.assertEqual(misplaced, []) if __name__ == "__main__": unittest.main()
-
-
README.md 3.1 KB
# Jellyfin Media Server from the Terminal Query your Jellyfin media library — recently added movies and episodes, search and inspect items, walk series, seasons, and episodes, browse library contents, see next-up episodes, log in as a user, and check server stats. ## Why Install This Skill When your agent loads this skill, it can **navigate your home media server** without opening a browser. That means: - **See what's new** — recently added movies and TV episodes, filtered server-side - **Search your library** — find any movie, show, or episode by keyword - **Navigate series** — walk a show's seasons and episodes, and see what's next unwatched - **Browse collections** — list your libraries and page through everything in them - **Authenticate properly** — log in as a user (or use Quick Connect) without fumbling Jellyfin's unusual `MediaBrowser` authorization header, which trips up most scripts - **Check server details** — server name, version, operating system, user count, counts Every command is read-only (plus a `login` helper), and `--dry-run` previews any request without touching the network. ## What You Get | Path | Purpose | |------|---------| | `SKILL.md` | Complete command reference with setup, gotchas, and recipes | | `scripts/jellyfin` | CLI for Jellyfin API operations (`--json`, `--dry-run`) | | `scripts/test_jellyfin_cli.py` | Offline test suite (all HTTP mocked) | | `references/auth-and-sessions.md` | The MediaBrowser header scheme, login flow, token channels, deprecation timeline | | `references/endpoint-catalog.md` | Endpoint-by-endpoint parameter and response-shape catalog | | `references/user-scoping-and-errors.md` | Which calls need a user id, and why queries 400/404 without one | | `references/gotchas-field-guide.md` | Wire-level failure signatures and version differences | | `references/worked-recipes.md` | Multi-step curl/jq and CLI workflows | | `references/quick-connect.md` | Passwordless Quick Connect login | | `evals/evals.json` | Behavioral eval cases including negative triggers | ## Quick Start ```bash scripts/jellyfin --help export JELLYFIN_URL="http://your-server:8096" export JELLYFIN_API_KEY="your-api-key" # Dashboard → API Keys export JELLYFIN_USER_ID="your-jellyfin-user-id" # required by user-scoped commands ``` ```bash scripts/jellyfin search --query "dune" --type Movie --json scripts/jellyfin recent --movies --limit 5 ``` No API key yet? Log in as a user instead — the script sends the pre-token `Authorization: MediaBrowser Client=..., Device=..., DeviceId=..., Version=...` header that `POST /Users/AuthenticateByName` requires and prints the values to export: ```bash scripts/jellyfin login --username alice --prompt ``` ## Triggers Load this when asking about Jellyfin, media server content, recently added movies or TV, next-up episodes, browsing your home media library, or Jellyfin API authentication. ## Requirements Python 3.8+ with `requests`. A running Jellyfin server (10.8+ behaviors assumed). Authentication: an API key (Dashboard → API Keys), a user access token via `login`, or Quick Connect. User-scoped commands also need a Jellyfin user id. -
SKILL.md 11 KB
--- name: jellyfin description: Query your Jellyfin media server from the terminal — recently added media, search, item details, series navigation, next-up episodes, library browsing, server info, and user login. Use when the user asks about Jellyfin, media servers, movies, TV shows, next episodes, or their media library. Do not use this skill for server installation, library management, playback control, or Emby/Plex servers. license: MIT compatibility: Requires Python 3.8+ and `requests`. Authenticate with JELLYFIN_API_KEY (Dashboard → API Keys), a user access token from `login`, or Quick Connect; user-scoped commands (`recent`, `next-up`, `item`, `seasons`, `episodes`) also need JELLYFIN_USER_ID or --user-id. metadata: tags: jellyfin, media-server, movies, tv, episodes, recently-added, library, home-media, api-client sources: https://api.jellyfin.org/, https://gist.github.com/nielsvanvelzen/ea047d9028f676185832e51ffaf12a6f --- # jellyfin — Jellyfin Media Server from the Terminal Query recently added movies and TV episodes, search and inspect media, walk series → seasons → episodes, browse libraries, see next-up episodes, log in as a user, and check server stats — all from your Jellyfin server's REST API. Every command is read-only except `login`. ## Setup 1. Make sure your Jellyfin server is running and accessible (default `http://localhost:8096`). 2. Pick an authentication route: - **API key** — Dashboard → **API Keys** → `+`. Administrator-level, no user identity: every user-scoped command then needs an explicit user id. - **User token** — run `scripts/jellyfin login --username NAME --prompt` once; it prints the values to export. 3. Set these environment variables: ```bash export JELLYFIN_URL="http://your-server:8096" # include protocol and port export JELLYFIN_API_KEY="your-api-key-here" # or JELLYFIN_TOKEN after `login` export JELLYFIN_USER_ID="your-jellyfin-user-id" # required by recent, next-up, item, seasons, episodes ``` Run the bundled CLI as `scripts/jellyfin`. `--help` and `--dry-run` work without credentials. ### How authentication works Jellyfin wants a `MediaBrowser`-scheme `Authorization` header on every call. The login endpoint requires its `Client=..., Device=..., DeviceId=..., Version=...` quartet **before any token exists** — the server rejects `POST /Users/AuthenticateByName` with `400 Error processing request.` otherwise. Afterwards the access token (or API key) rides the same header as `Token="..."`; the legacy `X-Emby-Token` header means the same thing and is scheduled for removal from Jellyfin 12.0. The bundled CLI sends the modern form and puts the token in exactly that one channel per request (never co-sends `X-Emby-Token`). See [references/auth-and-sessions.md](references/auth-and-sessions.md). ## Essential Commands ### Authentication — get a session ```bash scripts/jellyfin login --username alice --prompt # prints JELLYFIN_* exports echo "pw" | scripts/jellyfin login --username alice --password-stdin scripts/jellyfin login --username alice --dry-run --json # preview the pre-token header ``` `login` demonstrates the full researched sequence: complete pre-token MediaBrowser header → `POST /Users/AuthenticateByName` → capture `User.Id` + `AccessToken` → print the post-token header for reuse. It never echoes the password. ### info — Server information ```bash scripts/jellyfin info # server name, version, OS, user count scripts/jellyfin info --json ``` ### recent — Recently added media ```bash scripts/jellyfin recent # last 10 items added for JELLYFIN_USER_ID scripts/jellyfin recent --movies --limit 5 # server-side includeItemTypes filter scripts/jellyfin recent --episodes --limit 20 --json scripts/jellyfin recent --user-id USER_ID # override the env var ``` Hits `/Items/Latest` with `userId`; the response is a **bare JSON array** (no `Items` wrapper), and `groupItems` merges episodes by series, so treat it as "what's new". ### search — Search your media library ```bash scripts/jellyfin search --query "dune" # everything scripts/jellyfin search --query "dune" --type Movie # comma-separated types scripts/jellyfin search --query "star trek" --type Series,Episode --limit 5 --json ``` Search hits `/Search/Hints`; results carry `id` (with a deprecated `ItemId` twin on old servers — the CLI already prefers the modern field). ### Navigation — inspect items and walk series ```bash scripts/jellyfin search --query "dune" --type Movie --json # find an item ID scripts/jellyfin item --id ITEM_ID # full metadata (needs user) scripts/jellyfin seasons --series-id SERIES_ID # list seasons scripts/jellyfin episodes --series-id SERIES_ID --season-id SEASON_ID scripts/jellyfin next-up --limit 10 # next unwatched episodes scripts/jellyfin next-up --series-id SERIES_ID --user-id USER_ID ``` `item`, `seasons`, `episodes`, and `next-up` are user-scoped: they require `JELLYFIN_USER_ID` or `--user-id` and fail before any network call without one. ### libraries — browse a collection ```bash scripts/jellyfin libraries # library IDs and types scripts/jellyfin browse --library-id LIBRARY_ID --type Movie --limit 50 scripts/jellyfin browse --library-id LIBRARY_ID --start-index 50 # paginate scripts/jellyfin browse --library-id LIBRARY_ID --user-id USER_ID # userId sent explicitly ``` `libraries` reads `/Library/MediaFolders`, which is **admin-only** — non-admin tokens get 403 and should use `/UserViews` (see references). `browse` pages `/Items` with `startIndex`/`limit` and passes `userId` when provided, since servers using non-API-key auth reject unscoped queries with `400 userId is required`. ### stats — Library statistics ```bash scripts/jellyfin stats # movie, series, episode, song counts (/Items/Counts) ``` ## Pipeline recipes ### Find a series, then its next unwatched episode ```bash scripts/jellyfin search --query "breaking bad" --type Series --json | jq -r '.results[0].id' scripts/jellyfin next-up --series-id "$SERIES_ID" --user-id "$JELLYFIN_USER_ID" --json | jq -r '.items[0].name' ``` ### Page through a whole library ```bash scripts/jellyfin browse --library-id "$LIB_ID" --limit 100 --start-index 0 --json | jq -c '.items' # loop: advance --start-index by the returned count until .total_record_count is reached ``` ### Log in and persist a session ```bash scripts/jellyfin login --username alice --prompt --json | jq -r '"\(.user_id) \(.access_token)"' ``` ## JSON and jq Put `--json` before or after the subcommand. Output keys are stable snake_case: `items` (with `id`, `name`, `type`, `year`, `series`, `season_number`, `episode_number`), `results`, `libraries`, `total_record_count`, `start_index`. `--dry-run` emits a plan carrying `dry_run`, `path`, and `params` (`login` adds `authorization_header`; `info` composes a `requests` list), matching what would be sent, so jq can verify a chain before running it live. Exit codes: `0` success (including dry-run), `1` CLI/API errors, `2` argument errors. Use `jq -r '.items[] | [.name, .year] | @tsv'` for tabular handoff. ## Known Gotchas - **JELLYFIN_URL must include protocol and port** — e.g. `http://192.168.1.100:8096`. - **User-scoped commands require an explicit user** — `recent`, `next-up`, `item`, `seasons`, `episodes` refuse to run without `JELLYFIN_USER_ID`/`--user-id`. The CLI never picks an administrator for you. A missing userId on user-token requests makes the server answer `400 userId is required`. - **API keys have no user** — `/Users/Me` answers `400 Token is not owned by a user.` to API keys by design; per-user queries need an explicit user id (see [references/user-scoping-and-errors.md](references/user-scoping-and-errors.md)). - **The login 400 vs 401 trap** — missing/partial MediaBrowser header → `400` with plain text `Error processing request.`; wrong credentials → `401`. Same endpoint, different failures. - **Response shapes differ per endpoint** — `/Items` and `/Shows/*` wrap results in `{Items, TotalRecordCount, StartIndex}`; `/Items/Latest` returns a bare array; search uses a `SearchHints` key. Generic clients must branch (the CLI already does). - **Recent type filtering is server-side** — `--movies`/`--episodes` become `includeItemTypes` before `limit`; no local filtering. - **NextUp needs userId on every server version** — omitting it crashed servers ≤10.8 and silently scopes to the session user on ≥10.9. The CLI always sends it. - **`libraries` is admin-only** — `/Library/MediaFolders` requires an administrator token; non-admin tokens get 403. - **Legacy auth is going away** — `X-Emby-Token`, `X-MediaBrowser-Token`, and the `api_key` query parameter are deprecated; admins can already disable them (10.11+), and removal targets 12.0. Prefer the modern Authorization header the CLI sends. - **Lazy auth** — `--help` and `--dry-run` work without credentials; dry-run never touches the network. ## When to use Use this skill for read-only interaction with a running Jellyfin server: discovery of what's new, searching and inspecting items, walking series and seasons, next-up planning, library inventories, and obtaining a user session via `login` or Quick Connect. ## When not to use Do not use this skill for server installation or administration (installing Jellyfin or Emby, editing libraries, managing users) — every bundled command is read-only except `login`. It does not target Plex or Kodi (different APIs — use their own tools), and it is not a playback remote: route streaming or remote-control automation to Jellyfin's official clients. ## Reference Files | File | Use it for | | ---- | ---------- | | [references/auth-and-sessions.md](references/auth-and-sessions.md) | MediaBrowser header scheme, login flow, token channels, legacy deprecation, error signatures | | [references/endpoint-catalog.md](references/endpoint-catalog.md) | Every read endpoint's parameters, response shapes, image URLs, pagination loop | | [references/user-scoping-and-errors.md](references/user-scoping-and-errors.md) | The userId requirement matrix, API-key identity quirks, 400-vs-404 diagnosis | | [references/gotchas-field-guide.md](references/gotchas-field-guide.md) | Wire-level failure signatures, version-drift ledger, mock shapes | | [references/worked-recipes.md](references/worked-recipes.md) | Multi-step curl/jq and CLI recipes: login → latest, libraries → browse, search → seasons → episodes | | [references/quick-connect.md](references/quick-connect.md) | Passwordless Quick Connect login flow | ## Available Scripts and Prerequisites - `scripts/jellyfin` — the bundled Python CLI (`--json`, `--dry-run`, lazy auth). Imports only the standard library and `requests`. - `scripts/test_jellyfin_cli.py` — offline test suite (pytest + unittest compatible); all HTTP behavior is mocked, zero network egress. - Requires Python 3.8+ and `requests`. A running Jellyfin server (10.8+ assumed; tested behaviors anchored to the 12.0-era OpenAPI spec). No service is started by this skill.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.