watchparty/docs/architecture.md
Nicolas 66af05f259 Scaffold Emby Watch Party plugin
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>
2026-09-13 14:02:43 +02:00

202 lines
11 KiB
Markdown

# 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:
1. Track which sessions belong to which "party".
2. Watch each member's reported position/pause state (the same data Emby's
own "Now Playing" / dashboard already reads).
3. 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](https://github.com/MediaBrowser/Emby/wiki/How-to-build-a-Server-Plugin) — project setup, `BasePlugin<T>`, `IServerEntryPoint`.
- [Creating Api Endpoints](https://github.com/MediaBrowser/Emby/wiki/Creating-Api-Endpoints) — the `IService`/`[Route]`/`IReturn<T>` REST pattern.
- [Emby plugin dev docs](https://dev.emby.media/doc/plugins/dev/index.html) — confirms `netstandard2.0`, .NET Core/Mono runtimes.
- [`MediaBrowser.Server.Core` on NuGet](https://www.nuget.org/packages/mediabrowser.server.core) — package to reference; latest published at research time was `4.9.1.90`.
- Emby community thread on [remote-controlling seek](https://emby.media/community/topic/119432-emby-remote-control-another-client-to-seek-tracks/), and Jellyfin's (Emby-derived) [`SessionManager.cs`](https://github.com/jellyfin/jellyfin/blob/master/Emby.Server.Implementations/Session/SessionManager.cs) — shape of `SendPlaystateCommand(controllingSessionId, sessionId, PlaystateRequest, CancellationToken)` and `PlaystateRequest { 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 a
`Pause` command; if it unpauses, everyone gets `Unpause`.
- **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.
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:
- **`IHasWebPages` vs `IHasUIPages`** — different community plugins use
different names depending on SDK era; `Plugin.cs` assumes `IHasWebPages`.
- **`PluginPageInfo` property names** (`Name`, `EmbeddedResourcePath`).
- **ID types** — whether item/user/session IDs are `Guid` or `string` on your
SDK version (this has changed across Emby versions). `Party`/`PartyMember`
assume `Guid`.
- **How an `IService` reads the calling user** — `WatchPartyService` assumes
constructor-injected `IAuthorizationContext` + `IRequiresRequest.Request`,
then `GetAuthorizationInfo(Request).UserId`.
- **HTTP error mapping** — endpoints currently throw plain
`ArgumentException`/`InvalidOperationException`; Emby likely has a more
specific exception type (something like `ResourceNotFoundException`) that
maps to a proper 404/400 instead of a generic 500.
- **Static file content types** — `GET /WatchParty/app` etc. currently return
a bare string. The wiki confirms an `IHasResultFactory` extension point
exists for setting a real `Content-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 expect
`Microsoft.Extensions.Logging` instead.
- **`X-Emby-Authorization` header fields and `/Users/AuthenticateByName`'s
`Pw` field name** in `web/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.