Developers
Live events over WebSocket
Follow playback sessions, scans, and library changes as they happen, instead of polling the API on a timer.
Your server pushes what is happening on it over a WebSocket: who started or stopped playing something, progress through a title, scans, and items added to or removed from libraries. If you are building a dashboard or a bot, open this socket once and react to events rather than calling the API every few seconds. The event shapes are machine-readable in the API reference as the WsEvent schema; this page covers how to connect and how to use the events well.
Connect and sign in#
There is one socket per server, at /api/v1/ws on the same address you use for the API (ws:// for plain HTTP, wss:// for HTTPS). Opening the socket always succeeds. Sign-in happens inside it, so send this as your first text frame:
{"type": "auth", "token": "eyJhbGciOi..."}
The server answers {"type": "auth_ok"}, and only then do events start to flow. Don't treat the socket as connected until you have seen that frame. If sign-in fails, you get one frame such as {"error": "unauthorized"} and the server closes the socket. The possible values are listed in the reference as WsAuthErrorCode.
You can also pass the token in the URL (/api/v1/ws?token=...) and skip the first frame. Prefer the frame: URLs tend to end up in logs.
Use an account token from the device flow. Server API keys are not accepted on the socket today, so a script that signs in with a key still needs a device-flow token for live events.
What you receive#
Every event is one JSON text frame with a type field naming the event. A few examples:
{"type": "StreamStarted", "session_id": "5c0e...", "user_id": "a91f...", "username": "sam",
"media_id": "7d2b...", "media_title": "The Expanse S02E04", "play_method": "DirectPlay", "...": "..."}
{"type": "StreamProgress", "session_id": "5c0e...", "user_id": "a91f...", "progress_pct": 41.7, "position_ticks": 15120000000, "is_paused": false}
{"type": "StreamStopped", "session_id": "5c0e...", "user_id": "a91f...", "media_title": "The Expanse S02E04"}
The families you are most likely to want:
- Playback:
StreamStarted(user, title, codecs, how it is delivered),StreamProgress(position and, from server 0.13.1, whether the player is paused, as the player reports them),StreamUpdated(sent when the viewer changes quality, with the session's delivery details after the change), andStreamStopped. - Library:
MediaAdded,MediaUpdated,MediaDeleted. - Scans:
ScanStarted,ScanProgress,ScanCompleted,ScanFailed,ScanCancelled.
What you see depends on who signed in. An admin account receives playback events for everyone on the server, plus scan and refresh events. Any other account receives playback events for its own sessions only. Library events follow what the signed-in profile may browse: its libraries, and its content rating limit if it has one. A profile with a rating limit gets no MediaDeleted events at all, because a removed title's rating can no longer be checked. New event types can appear in any release, so ignore any type you don't recognize. Frames whose type starts with wt_ belong to Watch Together, which shares this socket.
Start from a snapshot#
Events tell you what changed, not what is already happening. On connect:
- Open the socket and wait for
auth_ok. - Fetch the current state once. For playback, an admin calls
GET /api/v1/system/playback/sessions. - Apply events on top, keyed by
session_id.
In that order, the socket is already listening while you fetch, so a change in between still reaches you. Keep reading the socket while the fetch runs, or at least don't let it stall for long.
The session list is lean: each entry has the session id, the user's id, the file, the client and device, and how it is delivered, but no title, username, or position. Those arrive on events. A session that starts after you connect brings everything with its StreamStarted. For one that was already running, you know who (look the user id up in GET /api/v1/users if you need a name) and which file, and its position fills in from the next StreamProgress.
When merging, treat every event as an update to the record you hold: add its fields to what you already know rather than replacing the record, so nothing from the snapshot or an earlier event is lost. StreamStopped ends the session.
Positions are position_ticks: 100-nanosecond units, ten million per second, the same unit the stream endpoints use.
Staying connected#
- Keepalive. The server pings every 30 seconds and closes a socket that hasn't answered a ping for about a minute. WebSocket libraries answer pings for you as long as your code keeps reading the socket; if yours doesn't, reply with a pong.
- Reconnect. The socket closes when the server restarts or updates, and when the signed-in profile's library access changes. Reconnect with a growing delay (1 second, then 2, 4, up to about 30) and repeat the snapshot step, because events are not replayed.
- Bursts. A client that reads too slowly during a burst, such as a large scan, can miss events, and the socket gives no notice when that happens. If being exactly right matters to you, refresh your snapshot now and then, for example every few minutes, rather than trusting the event stream forever.
- Token expiry. A socket stays signed in for as long as it is open. When it closes, reconnect with your current token, refreshing it first if it has expired.
A minimal watcher in Python#
Using the websockets package (pip install websockets):
import asyncio, json, time, urllib.request, websockets
SERVER = "192.168.1.50:33333"
TOKEN = "eyJhbGciOi..."
def snapshot():
req = urllib.request.Request(f"http://{SERVER}/api/v1/system/playback/sessions",
headers={"Authorization": f"Bearer {TOKEN}"})
with urllib.request.urlopen(req, timeout=10) as resp:
return {s["session_id"]: s for s in json.load(resp)}
async def watch():
delay = 1
while True:
started = time.monotonic()
try:
async with websockets.connect(f"ws://{SERVER}/api/v1/ws") as ws:
await ws.send(json.dumps({"type": "auth", "token": TOKEN}))
if json.loads(await ws.recv()).get("type") != "auth_ok":
raise SystemExit("sign-in refused")
# Fetch off the event loop so the socket keeps answering pings.
sessions = await asyncio.to_thread(snapshot)
async for frame in ws:
event = json.loads(frame)
kind, sid = event.get("type"), event.get("session_id")
if kind in ("StreamStarted", "StreamUpdated", "StreamProgress"):
sessions.setdefault(sid, {}).update(event)
if kind == "StreamStarted":
print("started:", event["username"], event["media_title"])
elif kind == "StreamStopped":
sessions.pop(sid, None)
print("stopped:", event.get("media_title"))
except (OSError, websockets.WebSocketException):
pass
# Closed, cleanly or not: wait, then reconnect and take a fresh snapshot.
# A connection that held for a minute counts as healthy and resets the delay.
if time.monotonic() - started > 60:
delay = 1
await asyncio.sleep(delay)
delay = min(delay * 2, 30)
asyncio.run(watch())