WebSocket Protocol Specification
Overview
JellyWatchParty uses a JSON-over-WebSocket protocol for real-time communication between clients and the session server.
Endpoint: ws(s)://<host>:3000/ws?client_id=<persistent-client-id>&resume=<resume-secret>
client_id and resume Query Parameters
The client generates a UUID once per browser tab, keeps it in
sessionStorage (it survives reloads of that tab; other tabs get their
own), and sends it as ?client_id= on every connection attempt
(including reconnects). This query param is what lets the server recognize “this is
the same client as before” across a dropped connection, so it can
reattach the client to its existing room membership (and resend
room_state) instead of treating it as brand new. Only values that look
like a real UUIDv4 are trusted; anything else is ignored and the server
mints a fresh ID instead.
Client ids are not secret (every room member sees them in
participants), so knowing one is not enough to take a session over.
Each new client entry gets a random resume secret, sent only to that
client in client_hello. A connection reattaches only if it also sends
that secret as &resume=. If the id is already in use and the secret is
missing or wrong, the server ignores the requested id and issues a new
one in client_hello; the client should then store the new id and
secret. The secret is replaced on every successful reattach, so always
store the one from the latest client_hello. Clients from before this change (no resume) still connect, but
lose their room on reconnect while the old entry is still held.
See Server: Persistent Client ID for the reattachment mechanics.
Message Format
All messages follow this structure:
{
"type": "message_type",
"room": "room_id",
"client": "client_id",
"payload": { ... },
"ts": 1678900000000,
"server_ts": 1678900000100
}
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | Yes | Message type |
room |
string | No | Room ID (if applicable) |
client |
string | No | Sender client ID |
payload |
object | No | Message-specific data |
ts |
number | Yes | Client timestamp (ms since epoch) |
server_ts |
number | No | Server timestamp (added by server) |
Client → Server Messages
auth
Authenticate with a JWT token (if authentication is enabled).
{
"type": "auth",
"payload": {
"token": "eyJhbGciOiJIUzI1NiIs..."
},
"ts": 1678900000000
}
list_rooms
Request the list of active rooms.
{
"type": "list_rooms",
"ts": 1678900000000
}
Response: room_list
create_room
Create a new watch party room.
{
"type": "create_room",
"payload": {
"name": "Movie Night",
"start_pos": 0.0,
"media_id": "abc123def456",
"password": "optional-room-password"
},
"ts": 1678900000000
}
| Payload Field | Type | Description |
|---|---|---|
name |
string | Room display name |
start_pos |
number | Initial position (seconds) |
media_id |
string | Jellyfin media ID (optional) |
password |
string | Optional room password. If set, join_room must supply a matching password (see below). Never echoed back to any client. |
started |
boolean | Optional. false asks for the start countdown: the host is not playing yet and holds its first play. true means the host is already playing. Leaving it out means the room has already started (no countdown), so hosts that don’t hold their first play, like the native Host Bridge or older web clients, never leave guests waiting. |
bridge_device_id |
string | Optional. Sent by the plugin’s in-panel bridges: the Jellyfin DeviceId of the session this connection stands in for (1-200 printable ASCII characters, else ignored). The admin panel shows such clients as Plugin bridge. Refused with error reason: "device_already_bridged" if another client in a room already drives that device. |
Response: room_state
Effects:
- Client becomes host
- Broadcast
room_listto all clients
join_room
Join an existing room.
{
"type": "join_room",
"room": "uuid-room-id",
"payload": {
"password": "required-if-room-has-one"
},
"ts": 1678900000000
}
| Payload Field | Type | Description |
|---|---|---|
password |
string | Required only if the room was created with a password. Not checked for a client that’s already a member of the room (e.g. a re-sent join after a panel refresh). |
bridge_device_id |
string | Optional; as for create_room. |
Response: room_state, or error with payload.reason: "wrong_password" if the password is missing/incorrect, or "room_not_found" (followed by a fresh room_list) if the room no longer exists.
After 5 wrong passwords within 60 s, the same user (keyed by user_id, i.e. the JWT sub, not the client id) is refused for the rest of that 60 s window with payload.reason: "too_many_attempts" and payload.retry_after_ms, without the password being checked. The throttle is per room and per user, so one user guessing can’t lock others out. A successful join clears the user’s count.
Effects:
- Client added to
room.clients - Client removed from
room.ready_clients - Broadcast
participants_updateto other participants
leave_room
Leave the current room.
{
"type": "leave_room",
"room": "uuid-room-id",
"ts": 1678900000000
}
Effects:
- If host leaves and other participants remain: the earliest-joined
remaining participant is promoted to host in place, broadcast
host_changed(room stays open) - If host leaves and no participants remain: room closes, broadcast
room_closed - If a non-host leaves: broadcast
participants_update - Broadcast
room_listto all
ready
Indicate client is ready to receive playback commands.
{
"type": "ready",
"room": "uuid-room-id",
"payload": {
"media_id": "abc123def456"
},
"ts": 1678900000000
}
Effects:
- Client added to
room.ready_clients - If
pending_playexists andall_ready(): triggers scheduled play
player_event
Send a playback event (host only).
{
"type": "player_event",
"room": "uuid-room-id",
"payload": {
"action": "play",
"position": 120.5
},
"ts": 1678900000000
}
| Payload Field | Type | Description |
|---|---|---|
action |
string | "play", "pause", "seek", or "buffering" |
position |
number | Current position (seconds) |
Behavior by action:
| Action | Server Behavior |
|---|---|
play |
If all_ready(): broadcast with target_server_ts = now + 1000ms. Otherwise: create pending_play |
pause |
Broadcast with target_server_ts = now + 300ms |
seek |
Broadcast with target_server_ts = now + 300ms |
buffering |
Broadcast with target_server_ts = now + 300ms (treat as paused) |
Effects:
- Updates
room.state - Updates
room.last_command_ts(cooldown) - Broadcasts to other participants
state_update
Periodic playback state update (host only).
{
"type": "state_update",
"room": "uuid-room-id",
"payload": {
"position": 125.3,
"play_state": "playing"
},
"ts": 1678900000000
}
| Payload Field | Type | Description |
|---|---|---|
position |
number | Current position (seconds) |
play_state |
string | "playing" or "paused" |
Server filtering:
- Ignored if
now - last_command_ts < 2000ms(cooldown) - Ignored if
now - last_state_ts < 500ms(rate limit) - Ignored if position moves back 0.5s-2s (HLS jitter)
- Ignored if position advances < 0.5s (insignificant)
- Always accepted if
play_statechanges
ping
Latency measurement and clock synchronization.
{
"type": "ping",
"payload": {
"client_ts": 1678900000000
},
"ts": 1678900000000
}
Response: pong
chat_message
Send a text message to the room.
{
"type": "chat_message",
"room": "uuid-room-id",
"payload": {
"text": "Hello everyone!"
},
"ts": 1678900000000
}
| Payload Field | Type | Description |
|---|---|---|
text |
string | Message text (max 500 characters) |
Effects:
- Message broadcast to all clients in the room (including sender)
- Rate limited by existing 30 msg/sec limit
Error responses:
"Chat message cannot be empty"- Empty or whitespace-only text"Chat message too long (max 500 characters)"- Text exceeds limit"Room ID required for chat"- Missing room ID
set_media
Change which item the room is watching (host only). Used both when the
host starts playing something after creating a room with no media, and
when the host switches to a different item mid-session (issue #71) —
otherwise media_id never changes after create_room.
{
"type": "set_media",
"room": "uuid-room-id",
"payload": {
"media_id": "abc123def456",
"position": 0
},
"ts": 1678900000000
}
| Payload Field | Type | Description |
|---|---|---|
media_id |
string | Required. 32-char hex Jellyfin item id |
position |
number | Starting position in seconds (default 0) |
Effects:
- No-op (nothing changed, nothing broadcast) if
media_idalready matchesroom.media_id, or the sender isn’troom.host_id - Sets
room.media_id, resetsroom.stateto{position, play_state: "paused"}, clearspending_play, and resetsready_clientsto just the host — so the host’s nextplaywaits (up to 2s, same as any pending play) for guests to load the new item before scheduling playback for everyone - Broadcasts
media_changedto every other room member - Broadcasts
room_listto all (so lobby cards refresh their poster)
Error responses:
"media_id is required"- Missingmedia_idin payload"Invalid media_id"- Not a 32-char hex string
client_status
A participant reports its own playback status, for the room’s participant list. Clients send it when the status changes, at most once per second.
{
"type": "client_status",
"room": "uuid-room-id",
"payload": { "status": "syncing" },
"ts": 1678900000000
}
status |
Meaning |
|---|---|
synced |
Guest is in sync with the host |
syncing |
Guest is catching up (drift correction) |
buffering |
Video is buffering |
loading |
Waiting for a scheduled play, or opening the room’s media |
idle |
Not in the player |
playing / paused |
The host’s own state |
Effects: ignored unless the value is one of the above, the sender is in the room and the status changed; otherwise everyone in the room gets participants.
Server → Client Messages
client_hello
Sent immediately after WebSocket connection. client_id may differ from
the requested ?client_id= (see above);
resume_secret is needed to reattach to this id later and is never sent to
anyone else. room_id is the room the server has this client in, or
null: a reattaching client that thinks it is in a room but gets null
was removed (or the room closed) while it was offline and should go back
to the lobby; if it is in a different room, room_state for that room
follows.
{
"type": "client_hello",
"client": "uuid-client-id",
"payload": {
"client_id": "uuid-client-id",
"resume_secret": "64-hex-chars",
"room_id": null
},
"ts": 1678900000000,
"server_ts": 1678900000000
}
room_list
List of active rooms.
{
"type": "room_list",
"payload": [
{
"id": "uuid-room-id",
"name": "Movie Night",
"count": 3,
"media_id": "abc123def456",
"has_password": false
}
],
"ts": 1678900000000,
"server_ts": 1678900000000
}
room_state
Full room state. Sent after create_room or join_room.
{
"type": "room_state",
"room": "uuid-room-id",
"client": "uuid-client-id",
"payload": {
"name": "Movie Night",
"host_id": "uuid-host-id",
"participant_count": 3,
"media_id": "abc123def456",
"state": {
"position": 120.5,
"play_state": "playing"
},
"chat_history": [
{
"client_id": "uuid-sender-id",
"username": "Alice",
"text": "Hello!",
"server_ts": 1678899990000
}
]
},
"ts": 1678900000000,
"server_ts": 1678900000000
}
| Payload Field | Type | Description |
|---|---|---|
started |
boolean | false until the room’s first play has gone out (see Start countdown). Clients treat a missing value as true. |
chat_history |
array | Up to the last 50 chat messages sent in this room, oldest first — empty for a freshly created room. Replayed on both initial join and reconnect-reattach so late joiners and reconnecting clients aren’t missing context. |
admin_moved |
boolean | Only present (true) when an admin put this client into the room from the admin panel. The client may have been in another room a moment ago; the server already took it out of that one (that room sees a normal leave). The web client drops its old room state and shows a toast. |
Sent after create_room, join_room, when an admin adds the client to a
room, and on reattachment after a
dropped-connection reconnect (see
Server: Reconnect and Room Lifecycle).
start_pending
The host pressed play for the first time, but not everyone is ready yet. Sent to the whole room, host included.
{
"type": "start_pending",
"room": "uuid-room-id",
"payload": { "timeout_ms": 10000 },
"ts": 1678900000000,
"server_ts": 1678900000000
}
Clients show “Waiting for everyone to be ready…”. The server starts when everyone has sent ready, or after timeout_ms at the latest.
Start countdown
A room’s first play (started is false) works differently from later ones:
- The host’s client keeps its video paused and sends
player_eventplay. It sends nostate_updatewhile waiting. If the host’s new item is still being confirmed (beforeset_media), the play is held and sent right afterset_media, so the server already knows which item everyone has to load. - If everyone is ready, the server starts right away; otherwise it sends
start_pendingand waits forreadyfrom everyone, up to 10 s. - The server sends
player_eventplayto everyone, host included, with"countdown": trueandtarget_server_ts3 s ahead, and marks the room started. All clients show 3, 2, 1 against server time and start attarget_server_ts.
With nobody else in the room there is no countdown ("countdown": false, the usual 1 s schedule). A room also counts as started if it was created with started: true or without started, or once the host sends a state_update with play_state: "playing". If the host’s client gets no answer within 15 s, it just plays. Later plays behave as before.
participants_update
Participant count update.
{
"type": "participants_update",
"room": "uuid-room-id",
"payload": {
"participant_count": 4
},
"ts": 1678900000000,
"server_ts": 1678900000000
}
participants
The room’s participant list, in join order. Sent to the whole room when someone joins or leaves, the host changes, or a status changes; also sent to the host on create and to a client that reattaches.
{
"type": "participants",
"room": "uuid-room-id",
"payload": {
"participants": [
{ "id": "uuid-a", "name": "Alice", "is_host": true, "status": "playing" },
{ "id": "uuid-b", "name": "Bob", "is_host": false, "status": "syncing" }
]
},
"ts": 1678900000000,
"server_ts": 1678900000000
}
status is the participant’s last client_status, or unknown before its first report. participants_update and client_left are still sent with the count, for older clients.
player_event
Playback command relayed from host.
{
"type": "player_event",
"room": "uuid-room-id",
"payload": {
"action": "play",
"position": 120.5,
"target_server_ts": 1678900001000
},
"ts": 1678900000000,
"server_ts": 1678900001000
}
| Payload Field | Type | Description |
|---|---|---|
action |
string | "play", "pause", "seek", or "buffering" |
position |
number | Reference position (seconds) |
target_server_ts |
number | Target server timestamp for execution |
Client processing:
- Enable
isSyncinglock (2s) - Calculate adjusted position with elapsed time
- Schedule action at
target_server_ts
state_update
Periodic state update relayed from host.
{
"type": "state_update",
"room": "uuid-room-id",
"payload": {
"position": 125.3,
"play_state": "playing"
},
"ts": 1678900000000,
"server_ts": 1678900000000
}
room_closed
The room was closed (host started a new room, room empty, or an admin closed it), or an admin removed this client from it.
{
"type": "room_closed",
"room": "uuid-room-id",
"payload": { "reason": "An admin removed you from the room", "removed": true },
"ts": 1678900000000
}
reason is shown to the user. removed is true when only this client
was taken out and the room itself goes on.
client_left
A participant left the room.
{
"type": "client_left",
"room": "uuid-room-id",
"client": "uuid-left-client-id",
"payload": {
"participant_count": 2
},
"ts": 1678900000000,
"server_ts": 1678900000000
}
| Payload Field | Type | Description |
|---|---|---|
participant_count |
number | Updated participant count after the client left |
host_changed
Someone else is host now. Sent when the host left (or disconnected past the reconnect grace period) while other participants remained — the earliest-joined remaining participant is promoted in place and the room stays open — and when an admin hands the host role to another member.
{
"type": "host_changed",
"room": "uuid-room-id",
"client": "uuid-new-host-id",
"payload": {
"host_id": "uuid-new-host-id",
"host_name": "Bob",
"participant_count": 2
},
"ts": 1678900000000,
"server_ts": 1678900000000
}
Client processing: update state.isHost (compare payload.host_id
to the local client_id) and force a full UI re-render — the host-only
Close/Leave button label only updates on a forced render, not the
normal fast-render path.
media_changed
The host changed which item the room is watching, via set_media.
Sent to every room member except the host (who already knows).
{
"type": "media_changed",
"room": "uuid-room-id",
"payload": {
"media_id": "abc123def456",
"position": 0
},
"ts": 1678900000000,
"server_ts": 1678900000000
}
Client processing (guest): reset sync state (readyRoomId,
syncStatus, lastSyncServerTs/Position/PlayState) as on join, call
ensurePlayback(media_id, position), then watchReady(). Until the
guest’s own current item matches payload.media_id, it should not
report ready for the new item, apply position corrections, or show a
“synced” status.
pong
Response to ping.
{
"type": "pong",
"payload": {
"client_ts": 1678900000000
},
"ts": 1678900000050,
"server_ts": 1678900000050
}
Client-side RTT calculation:
const rtt = Date.now() - payload.client_ts;
const serverOffset = server_ts + (rtt / 2) - Date.now();
chat_message
Chat message broadcast from server.
{
"type": "chat_message",
"room": "uuid-room-id",
"client": "uuid-sender-id",
"payload": {
"username": "Alice",
"text": "Hello everyone!"
},
"ts": 1678900000000,
"server_ts": 1678900000050
}
| Payload Field | Type | Description |
|---|---|---|
username |
string | Sender’s display name |
text |
string | Message text |
Client processing:
- Add message to local chat history (max 100 messages)
- If chat panel not visible, increment unread badge
- Render message in chat UI
error
Error response.
{
"type": "error",
"payload": {
"message": "Error description",
"reason": "wrong_password"
},
"ts": 1678900000000,
"server_ts": 1678900000000
}
| Payload Field | Type | Description |
|---|---|---|
message |
string | Human-readable error description |
reason |
string | Optional machine-readable code for errors a client may want to special-case. Currently "wrong_password", "too_many_attempts" and "room_not_found" from join_room, and "device_already_bridged" from create_room / join_room |
retry_after_ms |
number | Only with reason: "too_many_attempts": milliseconds until the user may try the room’s password again |
Sequence Diagram: Complete Session
Client A Server Client B
│ │ │
├── WebSocket connect ────►│ │
│◄─── client_hello ────────┤ │
│◄─── room_list ───────────┤ │
│ │ │
├── create_room ──────────►│ │
│◄─── room_state ──────────┤ │
│ ├─── room_list (broadcast) │
│ │ │
│ │◄── WebSocket connect ────┤
│ ├─── client_hello ────────►│
│ ├─── room_list ───────────►│
│ │ │
│ │◄── join_room ────────────┤
│◄─ participants_update ───┤─── room_state ──────────►│
│ │ │
│ │◄── ready ────────────────┤
│ │ │
├── player_event (play) ──►│ │
│ │ all_ready() = true │
│◄─ player_event ──────────┼─── player_event ────────►│
│ target_ts = T+1000 │ target_ts = T+1000 │
│ │ │
│ [T+1000ms] │ [T+1000ms]│
│ video.play() │ video.play()│
│ │ │
├── state_update ─────────►│ │
│ ├─── state_update ────────►│
│ │ │
├── ping ─────────────────►│ │
│◄─── pong ────────────────┤ │
│ │ │
├── leave_room ───────────►│ │
│ ├─── room_closed ─────────►│
│◄─── room_list ───────────┼─── room_list ───────────►│
│ │ │