Claude Skill

jellyfin

Query your Jellyfin media server from the terminal — recently added media,

LLM Mart · 0 points · 13 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download magnus919-agent-skills-jellyfin-addad86.zip · 46 KB
Part of magnus919/agent-skills — 145 skills

Install

skills CLI npx skills add https://github.com/magnus919/agent-skills/tree/main/jellyfin
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install magnus919-agent-skills@llmmart
Git 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 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

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

  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:
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, 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).
  • 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 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 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.
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.

No comments yet.

Reviews (0)

No reviews yet.

Related