# 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`, `IServerEntryPoint`. - [Creating Api Endpoints](https://github.com/MediaBrowser/Emby/wiki/Creating-Api-Endpoints) — the `IService`/`[Route]`/`IReturn` 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 + 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.