Architecture
System Overview
OpenWatchParty consists of three main components that work together to provide synchronized media playback.
┌─────────────────────────────────────────────────────────────────────────┐
│ Jellyfin Server │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ OpenWatchParty Plugin (C#) │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ ┌────────────────┐ │ │
│ │ │ ClientScript │ │ Configuration │ │ JWT Token │ │ │
│ │ │ Endpoint │ │ Page │ │ Endpoint │ │ │
│ │ └─────────────────┘ └─────────────────┘ └────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
│
│ HTTP (loads JS)
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Browser (Jellyfin Web) │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Web Client (JavaScript) │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │ State │ │ UI │ │Playback │ │ WS │ │ Utils │ │ │
│ │ │ Module │ │ Module │ │ Module │ │ Module │ │ Module │ │ │
│ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
│
│ WebSocket
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Session Server (Rust) │
│ ┌─────────────────┐ ┌─────────────────┐ ┌────────────────────┐ │
│ │ Room Manager │ │ Client Handler │ │ Message Router │ │
│ └─────────────────┘ └─────────────────┘ └────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
Components
1. Jellyfin Plugin (C#)
The plugin integrates with Jellyfin’s plugin system. It serves a JavaScript loader at /OpenWatchParty/ClientScript and allow-listed modules under /OpenWatchParty/Client/{path}, provides the administrator configuration page and diagnostics, and issues session tokens. The loader fetches 39 client modules in dependency waves; the module files are not concatenated into the loader response.
Files:
Plugin.cs- Plugin entry point and configuration page registrationControllers/- Client assets, token, diagnostics and signing-key endpointsConfiguration/PluginConfiguration.cs- Administrator settingsOpenWatchPartyDiagnosticsService.cs- Redacted status and session health checksWeb/configPage.html- Administrator configuration pagesrc/clients/jellyfin-web/plugin.js- Client loader
2. Session Server (Rust)
A lightweight WebSocket server that manages rooms and relays messages.
Responsibilities:
- Accept WebSocket connections
- Manage room lifecycle (create, join, leave, close)
- Relay playback events between clients
- Validate host permissions
- Filter state updates (anti-jitter and per-connection message limits)
- Schedule synchronized actions and pending plays
- Negotiate optional host transfer, participant status, shared guest controls and media queue capabilities
- Maintain a count-bounded room queue and server-side up-next countdown
Modules:
main.rs- Server setup, Warp configurationtypes.rs- Data structures (Client, Room, Message)routes.rs- Warp route filterstasks.rs- Background tasks (zombie cleanup, shutdown)messaging.rs- Message sending utilitiesauth.rs- JWT validation and invite-ticket validationutils.rs- Timestamp and shared utilitiesws/- WebSocket connection, dispatch, validation, pending play and handlersroom/- Membership, room closure and media queue state
3. Web Client (JavaScript)
The Jellyfin Web client consists of the plugin.js loader and 39 allow-listed JavaScript modules, injected by the plugin.
Responsibilities:
- Inject UI elements (button, panel, home section)
- Manage WebSocket connection to session server
- Intercept video playback events
- Apply synchronized playback commands
- Correct drift with playback rate adjustment
- Synchronize clocks with the server
- Negotiate optional protocol capabilities and report participant status
- Support a host-controlled media queue and a local guest sync adjustment
Modules:
plugin.js- Loader, script initializationstate.js- Global state and constantsutils/- Utility functions (log, media, misc, time, video)ui/- User interface (cards, home, indicators, render, styles, toasts)playback/- Video binding and sync (bind, play, sync)chat/- Text chat (input, messages)ws/- WebSocket communication (auth, connection, send, handlers/)app/- Initialization and cleanup (cleanup, lifecycle)
Data Flow
Joining a Room
Browser Server Host Browser
│ │ │
├── WebSocket connect ──────────►│ │
│◄─── client_hello ──────────────┤ │
├── auth (JWT by default) ──────►│ │
├── ping (clock sample) ────────►│ │
│◄─── auth_success ──────────────┤ │
│◄─── room_list ─────────────────┤ │
│ │ │
├── join_room ──────────────────►│ │
│◄─── room_state ────────────────┤ │
│ ├── participants_update ──────►│
│ │ │
├── ready ──────────────────────►│ │
│ │ │
Synchronized Playback
Host Browser Server Client Browser
│ │ │
├── player_event (play) ──────►│ │
│ │ │
│ [Validate host] │
│ [Calculate target_ts] │
│ │ │
│◄─── player_event ────────────┼─── player_event ──────────►│
│ target_ts = T+1000 │ target_ts = T+1000 │
│ │ │
│ [Wait for T] │ [Wait for T] │
│ │ │
│ video.play() │ video.play() │
│ │ │
Leaving a Room (Explicit Leave)
Participant Server Host
│ │ │
├── leave_room ───────────────►│ │
│ │ │
│ [Remove from room] │
│ [Update room state] │
│ │ │
│ ├── participants_update ───►│
│ │ (count decreased) │
│ │ │
│◄─── room_list ───────────────┤ │
│ │ │
[Back to lobby] │ │
Host Disconnect (Recovery or Transfer)
Host Server Participants
│ │ │
X (transport lost) │ │
│ │ │
[Detect disconnect] │
[Hold eligible host for 10s] │
│ │
[Recovery or grace expires] │
│ │
├── host_changed ───────────►│
│ (reclaimed/transferred) │
│ │
│ [Update host controls]
The grace period requires a JWT-authenticated host with a non-empty user ID
and host_transfer support. A new authenticated connection for that user can
reclaim the host role by joining the same room before the expiry task runs.
Otherwise, the first remaining participant in join order that supports host
transfer is promoted; the room closes if none is eligible. Hosts without grace
eligibility follow the transfer-or-close path immediately.
An explicit leave_room does not start this grace period. An explicit
close_room closes the room for everyone. See Room lifecycle.
Technology Stack
| Component | Technology |
|---|---|
| Plugin | C# (net10.0), Jellyfin 12 packages, target ABI 12.0.0.0, ASP.NET Core |
| Session Server | Rust, Warp, Tokio |
| Web Client | JavaScript (IIFE pattern) |
| Communication | WebSocket, JSON |
| Authentication | JWT required by default; insecure no-auth mode requires explicit server configuration |
| Containerization | Docker, Docker Compose |
State Management
Server State
SharedState = Arc<RwLock<ServerState>>
ServerState {
clients: HashMap<ClientId, Client>
rooms: HashMap<RoomId, Room>
}
Client {
sender: Sender<Message> // bounded channel
room_id: Option<RoomId>
user_id: String
user_name: String
authenticated: bool
message_count: u32
last_reset: Instant
last_seen: Instant
}
Room {
room_id: String
name: String
host_id: ClientId
clients: Vec<ClientId>
ready_clients: HashSet<ClientId>
pending_play: Option<PendingPlay>
state: PlaybackState
pending_host_reconnect: Option<PendingHostReconnect>
state_server_ts: u64
last_state_at: Option<Instant>
command_cooldown_until: Option<Instant>
statuses: HashMap<ClientId, ParticipantStatus>
queue: MediaQueue // includes up-next countdown and last_added cursor
}
This is an abbreviated view; types.rs defines the complete structs. Client
membership and room membership are changed under the same write guard. Socket
writes are handled by each client’s bounded sender and writer task.
Client State
OWP.state = {
ws: WebSocket,
roomId: string,
clientId: string,
isHost: boolean,
serverOffsetMs: number,
lastSyncPosition: number,
lastSyncServerTs: number,
isSyncing: boolean,
isBuffering: boolean,
// ... and more
}
Security Model
- Authentication: JWT authentication is required unless the server is explicitly configured for insecure development mode.
- Authorization: Only hosts can seek, publish
state_update, close the room or mutate its queue. Guest play/pause is available only when both the guest and current host negotiatedshared_playback_control. - Transport: WebSocket (ws://) or secure WebSocket (wss://)
- Rate limiting: JWT issuance is limited to 30 requests per user per fixed
one-minute window; the insecure no-token response bypasses that limiter. The
session server accepts at most 30 messages per connection in a one-second
window (
RATE_LIMIT_MESSAGES,RATE_LIMIT_WINDOW_MS). - Message size: WebSocket messages are limited to 64 KiB.
See Security Guide for detailed security configuration.
Operational Limits
Resource Constraints
| Resource | Bound | Notes |
|---|---|---|
| Participants per room | 20 | MAX_CLIENTS_PER_ROOM |
| Queued media items per room | 20 | MAX_QUEUE_ITEMS; count bound only, not a byte or process-memory budget |
| WebSocket message | 64 KiB | MAX_MESSAGE_SIZE |
| Client messages | 30 per one-second window | Per connection; RATE_LIMIT_MESSAGES and RATE_LIMIT_WINDOW_MS |
| JWT requests | 30 per user per fixed minute | Plugin token issuance only; insecure no-token responses do not consume this limit |
The queue item cap bounds item count only; it is not a bound on serialized queue bytes or total process RSS. Message-size and participant limits are separate controls.
Edge Cases and Behavior
Multiple Clients Joining Rapidly
When several clients join a room in quick succession:
- Server handling: Membership changes use the shared
ServerStatewrite lock. - Participant updates: Joining sends the room state and updated participant lists/statuses; these joins are not batched behind a 100ms timer.
- Ready mechanism: A play command waits up to
MAX_READY_WAIT_MS(2s) for every current room member to be ready. - Scheduling: Once ready, or when the wait expires, play is scheduled
PLAY_SCHEDULE_MS(1s) ahead. Each client applies the server timestamp using its clock offset.
Host Network Disconnect
When the host loses connection:
Host disconnects
│
▼
Server detects WebSocket close or stale connection
│
▼
Eligible authenticated host: hold role for 10 seconds
│
▼
Same user rejoins: reclaim host with new connection ID
Otherwise: transfer to first eligible remaining participant
│
▼
No eligible participant: close room and notify members
Notes:
- Each WebSocket receives a separate client ID, even for the same Jellyfin user.
- The server sends WebSocket control-frame heartbeats every 20s. Browsers answer these independently of JavaScript timers in background tabs.
- Zombie cleanup checks every 30s for connections inactive for more than 60s; these are detection intervals, not the host recovery window.
- An eligible host’s 10s grace begins when the server handles its disconnect.
- Explicit leave immediately transfers the host role to an eligible participant, or closes the room if none is eligible; Close room closes for everyone.
Clock Offset and Playback Drift
Client wall clocks can differ substantially. client_hello and pre-auth JSON
ping/pong estimate each client’s offset from server time; absolute wall-clock
skew alone does not determine playback drift. The client keeps the lowest-RTT
sample among its latest eight and applies EMA smoothing (α=0.4). It pings every
2 seconds until five valid samples, then every 30 seconds; an offset change
above 50 ms restarts the fast cadence.
Playback correction uses the room’s expected position, not a clock-skew band:
| Playback drift | Behavior |
|---|---|
| Below 0.04 s | Reset playback rate to 1.0 |
| 0.04 s to below 2 s | Adjust playback rate, clamped to 0.90–1.15 |
| 2 s or more | Seek to expected position after the initial-sync cooldown; during that cooldown, only drift above 10 s forces the seek |
| Buffering or video not ready | Defer playback correction |
Buffering and HLS Edge Cases
The client gates host state reports while buffering or while video readiness is
below readyState 3. The server filters unchanged-state position updates when
they are backward by 0.5–2 seconds or forward by less than 0.5 seconds, and
applies a 500 ms minimum state-update interval. After a playback command, a
monotonic 2 second cooldown filters unchanged-state updates; play-state changes
are accepted unless they conflict with the hold for an accepted guest command.
These intervals are separate from the client’s 8 second join sync cooldown and
10 second initial-sync drift threshold.
Room Capacity and Scaling
Design limits:
- 20 clients per room
- 20 queued media items per room
- All state is in memory; rooms are ephemeral and a server restart removes them
- This design is a single server process; no distributed room state is described here
At capacity:
Client attempts join
│
▼
Server checks room.clients.len() >= 20
│
▼
Returns error: "Room is full"
Client shows error message
Reconnection Behavior
When a client disconnects and reconnects:
| Scenario | Behavior |
|---|---|
| Eligible authenticated host rejoins during 10s grace | Reclaims host role with a new client ID |
| Host grace expires | Transfers to first eligible remaining participant, or closes room |
| Participant reconnects | Rejoins with a new client ID and re-syncs if the room still exists |
| Older server without host transfer | Host disconnect closes the room; client does not try to recover it |
| Server restart | All rooms lost, clients reconnect to empty server |
Auto-reconnect:
- Transport reconnect uses capped exponential backoff: 1s, 2s, 4s, 8s, 16s, then 30s; opening a socket resets the attempt counter
- Maintains
autoReconnect=truestate - Remembers
desiredRoomIdacross an unexpected close, then reauthenticates and rejoins; each authentication/rejoin wait has a 5s timeout - Explicit room leave cancels automatic room rejoin; client cleanup disables transport reconnection and cancels pending work
- Blocked authentication uses a separate retry policy; see Client connection lifecycle