JellyboxJellybox/Docs
← Self-hosting guide

Extensions

Plug Jellybox into any media source beyond Jellyfin. Extensions are small HTTP services that implement a fixed contract — Jellybox calls them whenever a tag points at content from that source. Anyone can build one in any language; the reference implementation is a single Node script.

No third-party extensions ship with Jellybox today. The framework is the surface — what you do with it is up to you.

What is an extension?

An extension is an out-of-process HTTP service that handles a single media source — anything you can reach over an API. It implements a small set of routes that Jellybox calls server-to-server: search the library, list playback clients, play an item.

Extensions are system-wide. An admin registers an extension once; every user on that Jellybox can then connect their own account to it. The extension owns provider credentials (API tokens, OAuth refresh tokens, etc.) end-to-end — Jellybox stores only an opaque accountIdthe extension hands back. That separation keeps third-party tokens out of Jellybox's threat model.

From the device's point of view, nothing changes. POST /api/playworks identically whether the tag is backed by Jellyfin or by an extension — Jellybox routes the request to the right backend.

Using extensions

As an admin: register an extension

Set the ADMINS environment variable to a comma-separated list of admin email addresses. Without it, no one can register extensions — fail-closed by default.

# .env (or your hosting provider's dashboard)
ADMINS=you@example.com,partner@example.com

Then sign in, open Settings → Extensions, paste the extension's base URL, and click Register. Jellybox fetches /manifest, generates a one-time bearer secret, and persists the row. Copy the secret into the extension's own configuration so it can verify Jellybox is allowed to call it.

The secret is shown once. If you lose it, remove and re-register the extension to mint a new one.

As any user: connect your account

Open Settings → Extensions and you'll see every registered extension. For each:

  • Connect— for credentials-based extensions, fill in the form fields the extension declared in its manifest. For OAuth, you'll be redirected to the provider, and back to Jellybox once you authorise.
  • Load clients — fetches the list of playback targets the extension knows about. Pick one as your default.
  • Disconnect — remove your connection without unregistering the extension for everyone else.

Once connected, the source dropdown on the tag form picks up the extension. Search, select, save — your tag now plays through that provider when scanned.

Building an extension

An extension is any HTTP server that implements the routes below. Pick whatever language and runtime you like — Node, Python, Go, Rust, a Lambda function, a Docker sidecar — Jellybox doesn't care.

Every protected route expects Authorization: Bearer <secret>, where <secret> is the value Jellybox showed at registration time. /manifest is the only public route — Jellybox needs to fetch it before a secret has been generated.

MethodPathPurpose
GET/manifestDescribe the extension. Public.
POST/authenticate/startOAuth only. Return the provider URL.
POST/authenticate/exchangeOAuth only. Swap code for accountId.
POST/authenticate/completeCredentials only. Validate fields, return accountId.
POST/searchFind items in the user's library.
GET/clientsList the user's playback targets.
GET/imageStream artwork bytes (proxied through Jellybox).
POST/playTrigger playback of an item on a client.

The exact request/response types live in src/lib/extensions/types.ts in the Jellybox repo — copy them into your extension to keep yours in sync.

Manifest

GET /manifest tells Jellybox what your extension is and how to talk to it.

{
  "name": "Reference Extension",
  "version": "0.1.0",
  "iconUrl": "https://my-ext.example.com/icon.png",
  "authFlow": "credentials",       // or "oauth"
  "authFields": [                  // shown in the connect form (credentials only)
    { "key": "demoToken", "label": "Demo token", "secret": true, "required": true }
  ],
  "capabilities": {
    "search":       true,
    "listClients":  true,
    "images":       false
  },
  "itemTypes": ["film"]            // free-form strings you'll return on items
}

When admins click Refresh manifest on a registered extension, Jellybox re-fetches this and updates the cached copy. Bump your version when capabilities or auth fields change.

Authentication

Each extension declares one of two flows. Jellybox uses different routes for each.

Credentials (token, username/password, etc.)

Set authFlow: "credentials" and list the form fields you need in authFields. Jellybox renders the form, then POSTs the values to /authenticate/complete:

POST /authenticate/complete
Authorization: Bearer <jellybox secret>
{ "fields": { "demoToken": "abc123…" } }

→ 200 { "accountId": "opaque-id", "displayName": "Demo User" }

Validate the fields with the provider, generate or look up a stable accountId for that user, and return it along with a human-readable display name. Jellybox stores only the accountId — the credentials never leave your extension.

OAuth

Set authFlow: "oauth". Jellybox handles the browser redirect so your extension never has to be publicly reachable — see the OAuth section below.

OAuth flow

Jellybox hosts the OAuth callback URL. The OAuth provider only ever talks to Jellybox; the extension is reached server-to-server. That means a self-hosted extension can stay on your private/Docker network without exposing any public ports.

Browser            Jellybox                   Extension                    Provider
   │                  │                          │                            │
 1 │  Connect ──────► │                          │                            │
   │                  │  /authenticate/start ──► │                            │
   │                  │  { state, callbackUrl }  │                            │
   │                  │ ◄─ { redirectUrl }       │                            │
 2 │ ◄── 302 ─────────┤                          │                            │
   │   (redirectUrl)  │                          │                            │
 3 │ ──────────────────────────────────────────────────────────────────────── │
   │                                                                          │
 4 │ ◄── 302 ── (callbackUrl?state=…&code=…) ──────────────────────────────── │
   │                  │                          │                            │
 5 │  /oauth/complete │                          │                            │
   │                  │  /authenticate/exchange►│                            │
   │                  │  { code, callbackUrl }   │                            │
   │                  │ ◄─ { accountId, name }   │ (extension talks to        │
   │                  │                          │  provider here, server-    │
   │                  │                          │  to-server, with its       │
   │                  │                          │  own client_secret)        │
  1. User clicks Connect — Jellybox mints an encrypted state token.
  2. Jellybox calls /authenticate/start with the state and the Jellybox callback URL.
  3. Extension returns a provider URL with state and redirect_uri baked in. Browser is redirected to it.
  4. User authorises at the provider.
  5. Provider redirects back to Jellybox's callback page with state and code.
  6. Jellybox decodes state and calls /authenticate/exchange server-to-server.
  7. Extension swaps the code for tokens with the provider, persists them, returns { accountId, displayName }.
The Jellybox callback URL is fixed: <your-jellybox>/dashboard/settings/extensions/oauth-callback. Register this exact URL with your OAuth provider — no per-extension subpath.

Refresh tokens

Jellybox never sees access or refresh tokens — only the opaque accountId. That means refresh handling is entirely your extension's responsibility.

A typical pattern:

async function getAccessToken(accountId) {
  const t = await store.get(accountId)
  if (Date.now() < t.expires_at - 30_000) return t.access_token

  const refreshed = await fetch('https://provider.example.com/oauth/token', {
    method: 'POST',
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: t.refresh_token,
      client_id:     process.env.CLIENT_ID,
      client_secret: process.env.CLIENT_SECRET,
    }),
  }).then(r => r.json())

  await store.update(accountId, refreshed)
  return refreshed.access_token
}

When refresh itself fails (revoked, password change, etc.), return AUTH_ERROR from /play — or any 401 from another route — and Jellybox will surface a playback failure that the user can resolve by reconnecting.

Hosting

An extension is just an HTTP server. Two common shapes:

  • Lambda / serverless function — simplest if your provider speaks public HTTP. Each route becomes one function; Jellybox calls them directly.
  • Docker sidecar— run alongside Jellybox on the same Docker network. The extension only needs to be reachable from Jellybox; it doesn't have to expose any public ports. The OAuth callback lives on Jellybox, so even OAuth providers don't need to reach your extension.

The shared bearer secret authenticates Jellybox-the-server to your extension — not individual users. Treat it like any service-to-service credential: rotate by re-registering if leaked.

Reference implementation

The Jellybox repository ships a working reference extension at examples/extension-reference/server.mjs — a single Node script with no dependencies. It implements every contract route with canned data and supports both auth flows via an AUTH_FLOW=oauth env toggle (with a fake provider screen for local testing).

Run it:

cd examples/extension-reference
node server.mjs                    # credentials mode (default)
AUTH_FLOW=oauth node server.mjs    # OAuth mode

Use it as a starter: copy server.mjs into a new project, replace the canned data with real provider calls, and you have an extension.