AetherNexus

Nexus · for client developers

Talking to a Nexus server

Nexus is a media server that runs on someone’s Mac, indexes the folders their films, shows and music are already in, and serves the original files. It converts nothing: what you ask for is what is on the disk, byte for byte. This page is what you need to reach one from your own app.

Everything here is the shipping contract, not a plan. Where the server has a capability it does not have, it says so in its own answers rather than leaving you to discover it at playback time.

Two ways in

There are two surfaces, and the right one depends on what your client already knows how to speak. They are not alternatives in capability — they read the same catalogue, enforce the same per-person folder grants and stream through the same code — so this is only a question of which wire format costs you less.

The Jellyfin-compatible API

If your app already talks to Jellyfin, you are close to done: the owner turns on one switch and your existing sign-in, browse and playback paths work. No Nexus-specific code.

Off by default. Browsing and playback only — no transcoding, no Live TV, no remote control.

The native API — /v1

Purpose-built, always on, and where features land first: multi-version films, season and episode structure, a music shelf, watchlists, progress sync with a cursor, device pairing, Live TV, and live server load.

JSON, one bearer token, ordinary HTTP range requests. This is what Aether uses.

Both are the same accounts and the same grants. A person who may see two of the household’s five folders sees exactly those two through either door, and an item outside them answers 404 rather than 403 — so a refusal can never be used to confirm that a file exists.

If your app already speaks Jellyfin

The owner enables it in Settings → Jellyfin-compatible API. Until they do, every route under it answers 404 — including GET /System/Info/Public, which Jellyfin publishes without a credential. That is deliberate: a server should not advertise a surface nobody asked it to expose. The switch is read on every request, so turning it off closes the door on clients that are already signed in.

Reaching it from a phone, a tablet or a TV also needs Listen on the local network switched on. On loopback alone, only a client running on that same Mac can connect.

What works

Authentication through POST /Users/AuthenticateByName with a Nexus username and password; views and libraries; browsing with ParentId, IncludeItemTypes, Recursive, SearchTerm, SortBy, SortOrder, StartIndex and Limit; seasons, episodes and Next Up; images; played and favourite state; PlaybackInfo and /Videos/{id}/stream; and the session reporting endpoints. Query parameters are matched without regard to case, because shipped clients send both spellings.

A token is accepted as a MediaBrowser authorization header, as X-Emby-Token / X-MediaBrowser-Token, or as ?api_key= in the query string. The last one exists because an AVPlayer or a <video> element cannot set a header, and it is what the DirectStreamUrl in a PlaybackInfo answer uses.

Identifiers

Jellyfin identifiers are 32 hexadecimal characters and clients treat them as such, so Nexus translates its own. An item or a library is already a UUID and simply loses its dashes, coming back exactly. A show, a season, a record or a performer is not a stored row — it is a grouping derived from the catalogue — so its handle is the first 128 bits of a namespaced SHA-256, resolved back by a lookup against the groupings the caller may see.

What it will never do

Every media source is advertised SupportsDirectPlay: true, SupportsDirectStream: false and SupportsTranscoding: false, with no TranscodingUrl. Your client therefore knows before it starts that the only offer is the file as it is, instead of waiting for a ladder that never arrives. If your own decoder cannot handle the file, the honest answer is that the server cannot help.

Also absent, and answering 404 rather than pretending: Quick Connect, Live TV, plugins, SyncPlay, remote control of other sessions, the metadata editor, playlists, collections, subtitle search and extraction, trickplay images, InstantMix, Persons, Genres, Studios. GET /Sessionsreturns an empty array — sessions exist, but exposing them would imply remote control that is not implemented. A file’s embedded subtitle tracks do appear in MediaStreams, which is all a client that plays the file itself needs.

The rest of this page is the native API. If the Jellyfin surface covers what you need, you can stop reading here.

Finding the server

A base URL is scheme://address:port, default port 8585, and your client appends /v1 exactly as it would to a hand-typed address. Ask the person for it, or find it on the local network.

While the local-network listener is up, the server advertises itself over Bonjour as _wellspring._tcp in local. — the service name predates the product name and is kept so that clients already looking for it keep working. The TXT record carries:

KeyMeaning
txtversTXT format version. Bumped only when an existing key changes meaning; ignore keys you do not know.
idStable server identity. Survives address changes, renames, restarts and client reinstalls — match on this, never on the instance name.
nameThe server name the household chose.
schemehttp or https. HTTPS means a locally generated CA the person must install on the device.
addrThe IPv4 address the listener is actually bound to.
portThe listener's TCP port.
vServer version string.
avAPI version, matching apiVersion from /v1/server.

Prefer addr and port over resolving the service yourself. A Mac holds several addresses — Wi-Fi, Ethernet, a bridge — while the listener answers on exactly one, so mDNS can hand you an address that is up but not listening.

When DHCP moves the Mac, the server rebinds and re-publishes with the same id and a new addr. A client that has signed in once should recognise the server by that identity and quietly update its stored URL instead of asking the person to type it again.

Discovery is a hint and authorizes nothing. The advertisement carries no credential and does not advertise the administration surface. Guest and “client isolation” Wi-Fi blocks multicast, so keep manual entry as the fallback.

Signing in

One call, a username and a password. owner is the administrator account; any other name is a person the owner invited. Usernames are matched without case sensitivity and the reply echoes the stored spelling.

POST /v1/auth/login
Content-Type: application/json

{"username":"owner","password":"…"}
200 OK
Cache-Control: no-store

{"accessToken":"<opaque>","tokenType":"Bearer",
 "expiresIn":2592000,"user":{"username":"owner"}}

Send it back as Authorization: Bearer <accessToken> on every protected route. The password-session token is a random 256-bit opaque value; the server stores only its SHA-256 digest and an absolute 30-day expiry. Expiry is not extended by use and password sessions cannot refresh — when one lapses, sign in again. Paired devices have a separate rotation flow below. POST /v1/auth/logout revokes the presented token and answers 204.

A token can also stop working before it expires: an owner password change, and for an invited person a password change, a withdrawn folder grant or a deleted account, all revoke immediately. For a password session, treat a 401as “ask for the password again”, not as a transient failure to retry.

Login shares a global limit of one attempt per second and answers 429 during the cooldown. An unknown username still performs a password derivation, so you cannot tell it from a wrong password by timing. Malformed JSON or missing fields give 400; the body is capped at 4 KiB.

GET /v1/auth/me reports who a token belongs to. GET /v1/serveris the server’s own description, and the one call worth making right after sign-in:

GET /v1/server

{"serverID":"…","name":"Vašek's Nexus","version":"0.5.12","build":"24",
 "apiVersion":1,"status":"ok","uptimeSeconds":48213.4,
 "libraryCount":3,"itemCount":1841,"availableCount":1839,"unavailableCount":2,
 "capabilities":{"directPlay":true,"remux":false,"transcode":false,"mediaProbe":true}}

Read capabilities and believe it. remux and transcode are false and will stay false: this server sends files, and a client that cannot decode one should say so rather than ask for a conversion. Counts are scoped to the libraries the caller may see. GET /health is public and answers {"status":"ok"} with no identity in it — useful for a reachability check before you have a credential.

Pairing a device

The owner generates a single-use code in Settings → People for the account the device should use. Redeem it without a bearer token:

POST /v1/auth/pair
Content-Type: application/json

{"code":"ABCD-EFGH","deviceName":"Living room TV","platform":"tvOS"}

deviceName is required, 1–64 characters; platform is optional. Code matching ignores case and punctuation. A successful response carries deviceID, accessToken, tokenType, expiresIn, refreshToken, refreshExpiresIn, username. Unknown, expired, cancelled or already-used codes return 401; pairing shares the login throttle and can return 429.

The access token lasts one hour and works as an ordinary bearer on protected native routes. The refresh token lasts 30 days and is single use:

POST /v1/auth/refresh
Content-Type: application/json

{"refreshToken":"<current refresh token>"}

No bearer is needed for refresh. Success returns a whole new device credential; store both new tokens together and use the new access token for subsequent requests. The old access token stops working immediately, but rotation does not interrupt an already-running file stream.

Serialize refreshes. Replaying a used refresh token revokes the entire device, including its replacement credentials. If a rotation response is lost, pair again instead of retrying the old token. A refused refresh returns 401.

POST /v1/auth/logoutwith the paired access token ends the pairing: the device is revoked and its refresh token destroyed. The owner can also revoke one device without signing out the rest of the household. Password changes and withdrawn library grants revoke the affected account’s pairings too.

Libraries and items

Every folder the owner approved is a library with a stable UUID. GET /v1/libraries returns the ones this caller may see, each with its name, item counts, a breakdown by kind, how many have been analysed, and the last scan time. A library added later simply appears.

GET /v1/libraries/{id}/items lists one library. GET /v1/items/{id} is the detail, and carries what was found inside the file: the audio and subtitle tracks, the video characteristics, and the matched metadata where a catalogue supplied it. For a film it also names the other versions of the same title, so you can offer the 1080p copy without listing the library first.

Structure has its own routes rather than being encoded in titles: /v1/series, /v1/series/{id}/seasons and /v1/series/{id}/seasons/{n}/episodes for television, /v1/movies and /v1/movies/{id}/versions for films. Series, album and artist IDs are deterministic identities derived from the library and a folded name — stable across rescans, scoped to one library, and never a filesystem path.

For a home screen, GET /v1/home returns continue-watching and recently-added in one call; /v1/continue and /v1/recent are the halves. limitdefaults to 20 and accepts 1–100; anything else is 400. Items carry addedAt, persisted at first discovery and unchanged by a rescan, a restart, or a file vanishing and coming back at the same path.

A path is never serialised, on any route. An item is a UUID, persisted against its location inside the approved folder, so it survives a restart and a replacement at the same path. Renaming a file creates a new identity — the old record is purged by the rescan that finds its file gone.

Playing the bytes

There is no playback session to open and nothing to prepare. Ask for the file, or part of it:

GET /v1/items/{id}/content
Authorization: Bearer <token>
Range: bytes=0-1048575

206 Partial Content
Content-Range: bytes 0-1048575/4823711744
Content-Length: 1048576
Accept-Ranges: bytes
ETag: "…"
Cache-Control: no-store

Accepted range syntax is bytes=start-end, bytes=start- and bytes=-suffix — one range, never several. An end past the end of the file is clamped; an oversized suffix returns everything with 206. Negative, reversed, empty, non-numeric, overflowing, out-of-bounds and multipart ranges all return an empty 416 with Content-Range: bytes */<size>, as does a range unit that is not bytes.

HEAD is authenticated, ignores Range and returns the full length, type and ETag with no body. A strong If-Range matching that ETag permits the range; any other validator returns the whole representation instead. There is no 304 handling, and no multipart.

A known item whose file is missing, unreadable or unsafe to open answers 410 — including on HEAD— which is the difference between “an unplugged drive, come back later” and the 404 that means the item is not yours to see. Catalogue records are kept while a volume is away rather than deleted.

Content type comes from the container: video/x-matroska for MKV, video/mp4, video/quicktime, and the matching audio types for music. Nothing is remuxed and nothing is re-encoded, so whatever you get is exactly what the person has on disk — which is also why a client with a strong demuxer gets much more out of this server than one without.

Aether plays these bytes with PrismCore, which is open source under LGPL and does the container work on the device. If you are writing for Apple platforms and want MKV to reach AVPlayer intact, it is the part you do not have to write.

Live TV

The native API serves a household-wide channel catalog and guide from M3U/M3U8, Xtream Codes and HDHomeRun sources. Live TV is not a folder library and has no per-library grants. These routes use a bearer token; the Jellyfin-compatible surface does not expose Live TV.

RouteWhat it answers
GET /v1/livetv/sourcesSource summaries and refresh status.
GET /v1/livetv/channels{page, limit, total, channels}; page starts at 1, limit is 1–500. Filter with search, group and sourceId.
GET /v1/livetv/groupsGroups with channel counts.
GET /v1/livetv/channels/{id}Channel detail, including streamURL and optional userAgent/referrer playback headers.
GET /v1/livetv/channels/{id}/logoLocally fetched logo bytes, or 404.
GET /v1/livetv/guide?channelIds=&from=&to={from, to, rows}; up to 500 server channel IDs, at most 24 hours. Defaults to now and the next two hours.
GET /v1/livetv/settings{refreshHours, windowDays, pastWindowHours}.
POST /v1/livetv/sourcesOwner only. Add {name, url, username?, password?, epgURL?}.
DELETE /v1/livetv/sources/{id}Owner only. Remove a source; 204, or 404 if unknown.
POST /v1/livetv/refreshOwner only. Refresh sources now; {refreshed}.
POST /v1/livetv/settingsOwner only. Update {refreshHours?, windowDays?}.

Playing through the relay

A client can play the channel’s provider URL directly, with its playback headers, or use the relay to share one upstream connection per tuned channel across devices. Send the channel ID and upstream URL to the server:

POST /v1/live/tune
Authorization: Bearer <accessToken>
Content-Type: application/json

{"channelID":"sport1","url":"https://provider.example/live/channel.ts",
 "headers":{"User-Agent":"MyClient"}}

Optional headerscarry the provider’s required headers; optional drm describes declared DRM. The channel ID must be 1–128 ASCII letters, digits, dots, underscores or hyphens. Success returns channelID, clientID, playlist, clientCount, sharedUpstream. Play the returned playlist path with bearer authentication:

RouteWhat it answers
GET /v1/live/channels/{channelID}/live.m3u8?client={clientID}Rolling HLS playlist. 503 with Retry-After: 1 while filling; 404 if not tuned; 502 on upstream failure.
GET /v1/live/channels/{channelID}/segments/{sequence}?client={clientID}Relayed media, initialization data or key bytes. 404 after a segment leaves the live window.
DELETE /v1/live/channels/{channelID}/clients/{clientID}204. Leave explicitly; otherwise inactive clients expire after 20 seconds.
GET /v1/live/relaysActive relays, client counts and upstream shape.

The playlist and its relative segment URLs already carry the client ID, which is not a credential: send the bearer header on both playlist and segment requests. MPEG-TS is cut into HLS segments; HLS upstream playlists are rewritten to local paths. Media is not decoded or re-encoded. The relay does not support MPEG-DASH or DRM-protected playback.

Relay refusals have named errors: live.drm-protected (403), live.channel-at-capacity or live.server-at-capacity (429), live.upstream-unreadable (502), and live.unsupported-format (415). Show the accompanying message; retrying cannot fix an unsupported format or DRM.

Progress, and syncing it

Report where the viewer got to. Position is the only required field; duration falls back to what the analysis found, then to the last duration you reported.

POST /v1/items/{id}/progress

{"positionSeconds":120,"durationSeconds":3600,"playedToCompletion":false}

Reaching 90% of a known duration marks completion, as does an explicit playedToCompletion: true; a later report below that threshold starts an unfinished viewing again, and false does not override the automatic completion. Position 0 removes the item from continue-watching. With an unknown duration there is no automatic threshold. Updates are last-write-wins, per person and item.

PUT and DELETE /v1/items/{id}/watched are the same row seen from the other side. Unwatching keeps the row rather than deleting it, so a syncing client sees the change as an update instead of a gap.

For synchronising a local store, GET /v1/progress?since=&limit= returns every row for the caller oldest change first: page forward with since set to the last updatedAt you saw. It must parse as ISO 8601 or you get 400. GET /v1/watched is the completed rows alone.

These return progress rows, not items — an identifier and a flag, because a library of watched films should not cost a library of item payloads. Rows for items in a folder the person has lost access to are hidden rather than deleted, and come back if the grant does. Unavailable items are included: a film whose disk is unplugged was still watched.

Treat the server’s rows as the truth for its items rather than reconciling them against a device-local watched store. There is one progress row per person per item, every owner credential shares the owner’s, and people never see one another’s.

GET /v1/watchlist, with PUT and DELETE on /v1/watchlist/{id}, is the per-person save-for-later list. GET /v1/activityis live server load, meant to be polled every few seconds only while a status screen is actually open — the owner sees every stream, an invited person only their own, and the host machine’s CPU and memory are owner-only.

Music

Audio lives in the same libraries, the same catalogue and the same identities as video, and streams through the same /v1/items/{id}/content with the same ranges and the same ETag. There is no second streaming path.

/v1/albums, /v1/artists and /v1/tracks are the shelves, with per-library variants and /v1/albums/{id}/tracks and /v1/artists/{id}/albums beneath them. A track carries its album, artist, album artist, track and disc numbers, genre and year.

Cover art is GET /v1/items/{id}/artwork, read straight back out of the file that carries it under the same ETag check streaming uses — 404 when the file has none. Nothing is cached on the server, so a replaced file cannot serve the previous sleeve.

Metadata comes from the file: ID3v2, iTunes atoms, Vorbis comments, then the Artist / Album / 01 Title folder layout where those are silent. A compilation is grouped by its album artist, so one record stays one record while each track keeps its own credit. A record reports a duration only once all of its tracks have been analysed, because a partial sum understates it.

Rules that bite

A 404 is not always absence

An item in a folder the caller was not granted answers 404, the same as one that does not exist. This is on purpose, so that a refusal cannot be used to probe what the household owns. Do not report it to the person as a missing file.

Tokens belong in headers

Never put one in a URL, a query parameter, a fragment or a log. The one exception is the Jellyfin surface’s ?api_key=, which exists only because a media element cannot set a header.

Cleartext on the LAN is cleartext

The plain-HTTP local listener is the default and carries passwords and media unencrypted, exactly as a stock Jellyfin install does. HTTPS is available through a locally generated CA the person installs on the device; validation stays on — never disable certificate checking to make it work.

No CORS, ever

No cross-origin headers are sent, so this is not an API you can reach from a web page you did not serve from the same origin. Native clients and your own backend only.

Responses are not cacheable

Every successful protected response and every 401 carries Cache-Control: no-store. Cache in your own layer, keyed by the ETag, rather than relying on an HTTP cache.

The error envelope is not final

Status codes are stable and are what you should branch on. The JSON body of a framework-level error is not yet a documented shape — do not parse it or match on its strings.

Route reference

The main client routes, with Live TV detailed above. Each needs Authorization: Bearer <token> except where noted, and each is scoped to the caller; library content follows folder grants, while Live TV is household-wide.

RouteWhat it answers
GET /healthPublic. {"status":"ok"} and nothing else.
POST /v1/auth/loginNo bearer required. Username/password → a 30-day bearer token. 401 on bad credentials, 429 in the cooldown.
POST /v1/auth/logout204. Revokes the token; for a paired device also ends the pairing.
POST /v1/auth/pairNo bearer required. Single-use code → device access and refresh tokens.
POST /v1/auth/refreshNo bearer required. Single-use refresh token → rotated device credentials.
GET /v1/auth/meWho the presented token belongs to.
GET /v1/serverIdentity, version, counts and capabilities.
GET /v1/activityLive sessions and throughput. Host load is owner-only.
GET /v1/librariesThe libraries this caller may see, with counts.
GET /v1/libraries/{id}/itemsOne library's items.
POST /v1/libraries/{id}/rescanOwner only. 204 after a persisted scan, 403 otherwise.
GET /v1/items/{id}Item detail, tracks, metadata, and a film's other versions.
GET|HEAD /v1/items/{id}/contentGET: original bytes, with Range (200/206/416). HEAD: full metadata, ignores Range. 410 when the file is away.
GET /v1/items/{id}/artworkCover art embedded in that file, or 404.
GET /v1/home{continueWatching, recentlyAdded} in one call.
GET /v1/continueUnfinished items, newest progress first.
GET /v1/recentRecently added, newest addedAt first. Also per library.
GET /v1/seriesShows, with /{id}/seasons and /seasons/{n}/episodes beneath.
GET /v1/moviesFilms, with /{id}/versions for the other copies of one title.
GET /v1/albumsRecords, with /{id}/tracks. Also per library.
GET /v1/artistsPerformers, with /{id}/albums and /{id}/tracks.
GET /v1/tracksEvery track, in playing order.
POST /v1/items/{id}/progressPersist a position. Only positionSeconds is required.
GET /v1/items/{id}/progressThis caller's row for one item, or 404.
PUT|DELETE /v1/items/{id}/watchedSet or clear completion, keeping the row either way.
GET /v1/progress?since=Every row, oldest change first — the sync cursor.
GET /v1/watchedCompleted rows only.
GET /v1/watchlistSaved for later, with PUT|DELETE /v1/watchlist/{id}.

The server’s administration API under /admin is deliberately not here. It needs an administrator session cookie rather than a bearer and is not a client surface. For settings without a documented native route, ask the person to open Settings on their Mac.

A whole client, in six calls

BASE=http://192.168.1.20:8585

TOKEN=$(curl -s "$BASE/v1/auth/login" \
  -H 'Content-Type: application/json' \
  -d '{"username":"owner","password":"…"}' | jq -r .accessToken)
AUTH="Authorization: Bearer $TOKEN"

curl -s "$BASE/v1/server"   -H "$AUTH"   # capabilities: transcode is false
LIB=$(curl -s "$BASE/v1/libraries" -H "$AUTH" | jq -r '.[0].id')
ITEM=$(curl -s "$BASE/v1/libraries/$LIB/items" -H "$AUTH" | jq -r '.[0].id')

curl -s -o /dev/null -D - "$BASE/v1/items/$ITEM/content" \
  -H "$AUTH" -H 'Range: bytes=0-1023'      # expect 206 + Content-Range

curl -s "$BASE/v1/items/$ITEM/progress" -H "$AUTH" \
  -H 'Content-Type: application/json' -d '{"positionSeconds":120}'

curl -s "$BASE/v1/continue" -H "$AUTH"     # it is in here now

Something missing?

The server is developed quickly and its repository is not public yet, so this page is the contract rather than a mirror of one. If you are building a client and something here is wrong, ambiguous, or absent, say so — that is the fastest way to get it fixed or added.

Get in touch

Nexus and Aether are built by Václav Zmrhal · About Nexus