Design: coordinate parties on top of Emby's existing session remote-control commands (SendPlaystateCommand) rather than building a new player. Each member keeps their own Emby app/controls; the server periodically corrects drift and mirrors pause/unpause against one reference member's reported position. - src/WatchParty: plugin skeleton (BasePlugin, IServerEntryPoint, IService/[Route] API, in-memory party store, drift-correction loop) - web/control-panel: plain HTML/JS/CSS companion page to create/join parties and resync, served by the plugin itself - docs/architecture.md: full design, sources consulted, and an explicit list of SDK details that still need verifying against a real build (no .NET SDK / Emby SDK assemblies available in this environment to compile against) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
11 KiB
Architecture
Goals
- Watch the same title, at (roughly) the same time, as other users on the same Emby server.
- Don't require anyone to change how they watch — no new client, no browser extension, no modified apps. People keep using whatever Emby app they already use.
- Ship as a single Emby Server plugin.
Non-goals (for v1)
- Frame-accurate sync. A few seconds of drift between corrections is acceptable — that's inherent to the "generic remote control" approach (see below), and is a conscious tradeoff, not an oversight.
- A shared/forced "host controls everyone" model. The chosen model (see "Control model" below) is: everyone keeps their own play/pause/seek controls; the party only shares a reference position to resync against.
- Cross-server parties, chat, reactions. Possible later, not in v1 — see Roadmap in the README.
The key idea: reuse Emby's existing remote-control commands
Emby already has an authoritative-session model for "cast" / "remote control
another device" style features: ISessionManager tracks every active
session's playback state, and a session can be commanded via
SendPlaystateCommand (Play, Pause, Unpause, Seek, ...) or
SendGeneralCommand. This already exists in Emby Server and, to whatever
extent each client supports being remote-controlled at all (most official
clients do, since it's how casting/remote-play works), in the clients too.
So instead of building a new synchronized player, this plugin is mostly a coordinator sitting on top of that existing mechanism:
- Track which sessions belong to which "party".
- Watch each member's reported position/pause state (the same data Emby's own "Now Playing" / dashboard already reads).
- When a member drifts too far from the party's reference position, or its pause state disagrees with the reference, send the same kind of Seek/Pause/Unpause command Emby would send for ordinary remote control.
Sources consulted while designing this:
- How to build a Server Plugin — project setup,
BasePlugin<T>,IServerEntryPoint. - Creating Api Endpoints — the
IService/[Route]/IReturn<T>REST pattern. - Emby plugin dev docs — confirms
netstandard2.0, .NET Core/Mono runtimes. MediaBrowser.Server.Coreon NuGet — package to reference; latest published at research time was4.9.1.90.- Emby community thread on remote-controlling seek, and Jellyfin's (Emby-derived)
SessionManager.cs— shape ofSendPlaystateCommand(controllingSessionId, sessionId, PlaystateRequest, CancellationToken)andPlaystateRequest { Command, SeekPositionTicks, ControllingUserId }.
Control model
Design decision: each participant keeps their own player and controls; the party only shares a reference position to resync against. Concretely:
- Nobody's play/pause/seek is disabled. You control your own playback exactly like you always do.
- One member is the reference (the host, by default — falls back to whoever's left if the host leaves). The server treats the reference session's reported position/pause state as ground truth for the party.
- Other members get corrected against the reference:
- Pause/unpause is mirrored immediately (configurable —
SyncPausePlay, on by default): if the reference pauses, everyone gets aPausecommand; if it unpauses, everyone getsUnpause. - Position drift beyond a threshold is corrected with a
Seek, on a cooldown so a shaky connection doesn't cause a stream of seek commands (DriftThresholdSeconds,ResyncCooldownSeconds). - Anyone can also hit "Resync me now" in the control panel for an immediate correction, bypassing the cooldown.
- Pause/unpause is mirrored immediately (configurable —
This deliberately does not try to stop someone from pausing or seeking locally — it just keeps pulling things back toward the reference. That fits "loose sync via existing remote-control commands" much better than trying to fight every local action.
Sync algorithm
Runs on a timer (SyncTickSeconds, default 5s) inside PartySyncEntryPoint,
per active party:
reference = party.Members[party.ReferenceUserId]
referencePos = reference.EstimatedPositionTicks(now) # extrapolated if playing
for member in party.Members, excluding reference:
if SyncPausePlay and member.IsPaused != reference.IsPaused:
send Pause/Unpause to member's session
continue
if now - member.LastResyncUtc < ResyncCooldownSeconds:
continue # give the last correction time to take effect
drift = abs(member.EstimatedPositionTicks(now) - referencePos)
if drift > DriftThresholdSeconds:
send Seek(referencePos) to member's session
EstimatedPositionTicks extrapolates from the last known position using
elapsed wall-clock time when playing, so members aren't marked "drifted" just
because their last progress event was a few seconds ago.
Position/pause state itself comes from Emby's own session events
(ISessionManager.PlaybackStart / PlaybackProgress / PlaybackStopped) —
the plugin doesn't poll clients itself.
Which session is "mine"?
The trickiest part of gluing "a user" to "a specific playback session", since one user can have several active sessions (phone + TV + browser tab).
v1's blunt heuristic (WatchPartyService.FindCallerSession): when you
create/join a party, the server looks at your currently active sessions,
keeps the ones actually playing something (and, when joining, playing the
same item as the party), and picks the most recently active one. Good
enough for "one person, one device, actively watching" — the expected case —
but it's a real limitation if you're doing something else on a second device
at the same time. Documented here rather than hidden.
Components
src/WatchParty/
Plugin.cs BasePlugin<PluginConfiguration> + admin settings page
Configuration/ PluginConfiguration (defaults) + configPage.html (dashboard UI)
Parties/
Party.cs party id, join code, item id, host/reference user, members
PartyMember.cs per-member session id + last known position/pause + EstimatedPositionTicks
PartyManager.cs in-memory store (ConcurrentDictionary); create/join/leave/lookup
Sync/
PartySyncEntryPoint.cs IServerEntryPoint: session event wiring + the tick loop above
Api/
WatchPartyService.cs REST endpoints (create/join/leave/get/resync) +
serves web/control-panel's static files
web/control-panel/ plain HTML/JS/CSS control panel (sign in, create/join,
member list with a sync-status dot, resync/leave)
Parties are in-memory only — a server restart clears them. That's fine for v1: a party is meant to live for one watch session, not persist for weeks.
The control panel is served by the plugin itself (GET /WatchParty/app,
/app.js, /app.css, reading embedded resources) rather than injected into
Emby's main web client. Emby doesn't document a stable way to inject a
user-facing (non-admin) page/script into the main web client, and hand-patching
the client's install directory is a well-known but fragile hack that breaks
on every Emby update — not worth it for v1. The tradeoff: opening the control
panel is a separate step from opening Emby, rather than a button inside the
Emby UI. It authenticates with your normal Emby username/password (via Emby's
standard /Users/AuthenticateByName) and only ever talks to your own server.
Assumptions to verify
This was designed by reading Emby's public docs/wiki and a couple of
community plugins (linked above), without a local copy of the SDK to compile
against (no .NET SDK was installed in the environment this was scaffolded in,
and the exact MediaBrowser.Server.Core build for Emby Server 4.9.5.0 wasn't
available to inspect directly). Treat the overall shape as solid and these
specifics as needing a compile-and-fix pass:
IHasWebPagesvsIHasUIPages— different community plugins use different names depending on SDK era;Plugin.csassumesIHasWebPages.PluginPageInfoproperty names (Name,EmbeddedResourcePath).- ID types — whether item/user/session IDs are
Guidorstringon your SDK version (this has changed across Emby versions).Party/PartyMemberassumeGuid. - How an
IServicereads the calling user —WatchPartyServiceassumes constructor-injectedIAuthorizationContext+IRequiresRequest.Request, thenGetAuthorizationInfo(Request).UserId. - HTTP error mapping — endpoints currently throw plain
ArgumentException/InvalidOperationException; Emby likely has a more specific exception type (something likeResourceNotFoundException) that maps to a proper 404/400 instead of a generic 500. - Static file content types —
GET /WatchParty/appetc. currently return a bare string. The wiki confirms anIHasResultFactoryextension point exists for setting a realContent-Type/caching headers; wiring that up is a follow-up rather than done here. - Logging API shape —
ILogManager.GetLogger(name)/ILogger.Debug(...)/.ErrorException(...)is the classic Emby/MediaBrowser pattern; some SDK generations may expectMicrosoft.Extensions.Logginginstead. X-Emby-Authorizationheader fields and/Users/AuthenticateByName'sPwfield name inweb/control-panel/app.js— long-standing Emby/ Jellyfin convention, not re-verified against 4.9.5.0 specifically.
The fastest way to nail all of these down: install the .NET SDK, dotnet restore the project (pulls the real MediaBrowser.Server.Core /
MediaBrowser.Common assemblies from NuGet), and let the compiler point at
whatever doesn't match.
Known limitations
- Sync is best-effort and loose by design (see "Non-goals"). Don't expect frame-accuracy.
- Depends on the member's own client actually honoring remote Seek/Pause commands smoothly — most official Emby apps do (it's how casting/remote control works today), but behavior can vary by platform, and a Seek can cause a visible re-buffer.
- One item per party; no shared "up next" queue.
- No chat/reactions in v1 (kept out on purpose — see the design conversation that led to this repo).
- A user with two simultaneously active sessions on the same item is resolved by "most recently active", which can guess wrong.