Fix plugin to build clean against real Emby 4.9.1.90 SDK

Installed .NET 8 SDK (per-user, winget needed admin elevation this
machine doesn't have) and restored the real MediaBrowser.Server.Core /
MediaBrowser.Common packages, then fixed everything the compiler and
a bit of reflection surfaced:

- PlaybackProgressEventArgs/PlaybackStopEventArgs are in
  MediaBrowser.Controller.Library, not ...Session
- IAuthorizationContext/AuthorizationInfo are in MediaBrowser.Controller.Net
- Item/user/session ids (SessionInfo.Id/UserId, BaseItemDto.Id) are plain
  string on this SDK version, not Guid - Party/PartyMember updated
  accordingly (Party.Id stays a Guid; it's minted by this plugin, not Emby)
- Reading the calling user needed an extra step:
  IAuthorizationContext.GetAuthorizationInfo(Request).UserId is a `long`
  (internal row id); .User.Id is the one matching SessionInfo.UserId,
  but it's a Guid so it's reformatted with .ToString("N")

docs/architecture.md's "Assumptions to verify" now reflects what was
actually confirmed vs. what's still only compile-checked (HTTP status
codes, content-type headers, the JS auth header format, the dashboard
config page) pending a run against a real server.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-13 14:40:17 +02:00
parent 66af05f259
commit db815df7d5
8 changed files with 133 additions and 85 deletions

View file

@ -27,20 +27,23 @@ the actual video stream.
## Status
Early scaffold, not yet built or run against a real server. It's structured
around Emby's documented plugin patterns (`BasePlugin<T>`, `IServerEntryPoint`,
the `IService`/`[Route]` API style) and the confirmed
`ISessionManager.SendPlaystateCommand` remote-control API, but several SDK
details are marked `TODO(verify)` in the code — see
[docs/architecture.md](docs/architecture.md#assumptions-to-verify) before
expecting it to compile as-is.
Builds clean (0 errors, 0 warnings) against the real `MediaBrowser.Server.Core`
/ `MediaBrowser.Common` 4.9.1.90 assemblies from NuGet. Not yet installed or
run against an actual Emby Server — a few things (auth header format, HTTP
status codes, the dashboard settings page rendering) are only compile-checked
so far, not runtime-verified. See
[docs/architecture.md](docs/architecture.md#assumptions-to-verify) for
exactly what was wrong on the first guess, what was fixed, and what's still
open.
## Requirements
- Emby Server (developed against 4.9.5.0; should work on nearby 4.9.x/4.8.x
builds, but the plugin ABI does shift between versions)
- [.NET SDK](https://dotnet.microsoft.com/download) to build (not currently
installed in this environment — `dotnet --version` came back empty)
- [.NET SDK](https://dotnet.microsoft.com/download) 8.0 to build (installed
per-user under `%USERPROFILE%\.dotnet` in this environment, since the
machine-wide winget install needed admin elevation that wasn't available —
see git history if you need to redo this on another machine)
## Project layout
@ -74,7 +77,7 @@ portable install vs. Docker volume).
## Roadmap
- [x] Design doc + scaffold
- [ ] Build against the real SDK, fix whatever the `TODO(verify)` spots got wrong
- [x] Build against the real SDK, fix whatever the `TODO(verify)` spots got wrong
- [ ] Try it against a real Emby Server + two clients
- [ ] Live (WebSocket) updates in the control panel instead of polling
- [ ] Optional text chat, reusing the same realtime channel

View file

@ -150,42 +150,73 @@ 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:
Update: the .NET SDK is now installed and `src/WatchParty` **builds clean
(0 errors, 0 warnings) against the real `MediaBrowser.Server.Core` /
`MediaBrowser.Common` 4.9.1.90 assemblies**, restored from NuGet. Most of the
list below turned out right on the first guess; a few didn't, and were fixed
by reading the actual assemblies (via .NET reflection) and then letting the
compiler confirm. Keeping the history here since it's a useful map of where
this SDK surprises you.
- **`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
**Confirmed correct as originally written** (compiled with no changes needed):
- `BasePlugin<T>` constructor shape, `IServerEntryPoint`, `netstandard2.0` +
`MediaBrowser.Server.Core` package — all from the official wiki.
- `IHasWebPages` (not `IHasUIPages` — that name does exist too, in
`MediaBrowser.Model.Plugins.UI`, but `IHasWebPages` in
`MediaBrowser.Model.Plugins` is the one that's actually wired up here) and
`PluginPageInfo.Name`/`.EmbeddedResourcePath`.
- `ISessionManager.SendPlaystateCommand(string controllingSessionId, string
sessionId, PlaystateRequest, CancellationToken)`, `SendGeneralCommand`, and
`PlaystateRequest { Command, SeekPositionTicks }` / `PlaystateCommand.{Pause,
Unpause, Seek}`.
- `ILogManager.GetLogger(name)` / `ILogger.Debug(...)` /
`.ErrorException(...)` — still the live pattern on 4.9.x, not replaced by
`Microsoft.Extensions.Logging`.
- `IRequiresRequest` / `IService` / `[Route]` / `IReturn<T>` for the REST
endpoints.
- `SessionInfo.PlayState.PositionTicks` (`long?`) and `.IsPaused` (`bool`).
**Wrong on the first guess, fixed once the real assemblies were available:**
- **Namespaces**, not names: `PlaybackProgressEventArgs`/`PlaybackStopEventArgs`
live in `MediaBrowser.Controller.Library`, not `...Session`.
`IAuthorizationContext`/`AuthorizationInfo` live in `MediaBrowser.Controller.Net`.
- **ID types**: item/user/session ids (`SessionInfo.Id`, `SessionInfo.UserId`,
`BaseItemDto.Id`) are plain **`string`**, not `Guid`, on this SDK version.
`Party`/`PartyMember` were changed from `Guid` to `string` accordingly.
(`Party.Id` itself stays a `Guid` — that's an id this plugin mints, not an
Emby one.)
- **Reading the calling user is a two-step, not one property**:
`IAuthorizationContext.GetAuthorizationInfo(Request).UserId` is a `long`
(an internal row id — not the id used anywhere else). The one that matches
`SessionInfo.UserId` etc. is `.User.Id` — except that one comes back as a
`Guid`, so `WatchPartyService.CallerUserId()` reformats it with
`.ToString("N")` to match the string convention used elsewhere. See the
comment there.
**Still only compile-checked, not verified against a running server**
(nothing here fails to build — these are runtime behaviors the compiler
can't check):
- Whether `.ToString("N")` (no dashes) is really the right string format to
match `SessionInfo.UserId` — plausible, matches Emby/Jellyfin convention
elsewhere, not observed directly.
- HTTP error mapping — endpoints 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.
specific exception type that maps to a proper 404/400 instead of a
generic 500.
- Static file content types — `GET /WatchParty/app` etc. return a bare
string; `IHasResultFactory` looks like the right extension point for a
real `Content-Type`/caching headers, not wired up yet.
- `controllingSessionId` passed as `null` to `SendPlaystateCommand` — compiles,
unknown whether Emby's runtime wants a real session/user there instead.
- `X-Emby-Authorization` header fields and `/Users/AuthenticateByName`'s `Pw`
field name in `web/control-panel/app.js` — it's JS, nothing compiles it;
long-standing Emby/Jellyfin convention, not exercised against a real server.
- The dashboard settings page (`configPage.html`) actually rendering/saving
correctly — embedding compiles, but the page's own JS (`ApiClient`,
`Dashboard` globals) only exists inside a running Emby dashboard.
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.
The remaining way to close these out: install the plugin DLL on a real Emby
Server 4.9.x and exercise it with two accounts.
## Known limitations

View file

@ -2,6 +2,7 @@ using System;
using System.IO;
using System.Linq;
using System.Threading;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Services;
using MediaBrowser.Model.Session;
@ -10,15 +11,23 @@ using WatchParty.Parties;
namespace WatchParty.Api
{
// ---------------------------------------------------------------------------
// The [Route]/IService/IReturn<T> pattern here is confirmed against Emby's
// official wiki: https://github.com/MediaBrowser/Emby/wiki/Creating-Api-Endpoints
// Builds clean against the real MediaBrowser.Server.Core/MediaBrowser.Common
// 4.9.1.90 assemblies. The [Route]/IService/IReturn<T> pattern matches Emby's
// official wiki (https://github.com/MediaBrowser/Emby/wiki/Creating-Api-Endpoints),
// and reading the calling user turned out to need one extra step versus the
// first guess: IAuthorizationContext.GetAuthorizationInfo(Request).UserId is a
// `long` (an internal row id, not useful for matching against SessionInfo.UserId
// etc.) - .User.Id is the right one, but it's a Guid rather than the string
// those other properties use, so it's reformatted with .ToString("N") below
// (Emby's usual convention for string ids; not independently confirmed beyond
// "the project builds", since that part is a runtime format match, not
// something the compiler checks - see docs/architecture.md#assumptions-to-verify).
//
// Less certain - see docs/architecture.md#assumptions-to-verify:
// - How a service reads the calling user (assumed here: constructor-injected
// IAuthorizationContext + IRequiresRequest.Request, then
// GetAuthorizationInfo(Request).UserId).
// Still only compile-checked, not run against a real server - see
// docs/architecture.md#assumptions-to-verify:
// - Exact HTTP-error exception types (using plain ArgumentException /
// InvalidOperationException below as a placeholder).
// InvalidOperationException below as a placeholder - they compile fine,
// but Emby likely has something that maps more precisely to a 404/400).
// - Content-Type on the static file responses (returning bare strings for
// now; IHasResultFactory looks like the right extension point later).
// ---------------------------------------------------------------------------
@ -71,15 +80,15 @@ namespace WatchParty.Api
{
public Guid PartyId { get; set; }
public string JoinCode { get; set; }
public Guid ItemId { get; set; }
public Guid HostUserId { get; set; }
public Guid ReferenceUserId { get; set; }
public string ItemId { get; set; }
public string HostUserId { get; set; }
public string ReferenceUserId { get; set; }
public PartyMemberDto[] Members { get; set; }
}
public class PartyMemberDto
{
public Guid UserId { get; set; }
public string UserId { get; set; }
public string UserName { get; set; }
public bool IsPaused { get; set; }
public long PositionTicks { get; set; }
@ -175,12 +184,21 @@ namespace WatchParty.Api
public object Get(GetControlPanelCss request) => ReadEmbedded("app.css");
private Guid CallerUserId()
private string CallerUserId()
{
return _authContext.GetAuthorizationInfo(Request).UserId;
// AuthorizationInfo.UserId is a `long` (an internal row id) on this SDK
// version - NOT the same id used everywhere else (SessionInfo.UserId,
// BaseItemDto.Id, ...). .User.Id is the one that matches, but it's a
// Guid rather than the string those other properties use - confirmed by
// an actual compiler error (CS0029) while building this file. Formatted
// as "N" (no dashes) to match Emby's usual string-id convention.
// TODO(verify) if session/party lookups ever silently miss: confirm
// SessionInfo.UserId really is Guid.ToString("N") and not "D"/some other
// format on your exact server build.
return _authContext.GetAuthorizationInfo(Request).User.Id.ToString("N");
}
private SessionInfo FindCallerSession(Guid userId, Guid? expectedItemId)
private SessionInfo FindCallerSession(string userId, string expectedItemId)
{
var candidate = _sessionManager.Sessions
.Where(s => s.UserId == userId && s.NowPlayingItem != null)

View file

@ -14,21 +14,24 @@ namespace WatchParty.Parties
/// <summary>Short human-typeable code used to join, e.g. "K7QX2P".</summary>
public string JoinCode { get; set; }
/// <summary>The library item everyone is meant to be watching.</summary>
public Guid ItemId { get; set; }
/// <summary>The library item everyone is meant to be watching. Emby's own
/// item/user ids are plain strings on this SDK version (confirmed by
/// building against the real MediaBrowser.Model assembly) - only Party.Id
/// itself (below) is a Guid we mint ourselves.</summary>
public string ItemId { get; set; }
public Guid HostUserId { get; set; }
public string HostUserId { get; set; }
/// <summary>
/// Whose reported position is treated as ground truth. Defaults to the
/// host, but falls back to whoever's left if the host leaves (see
/// PartyManager.Leave).
/// </summary>
public Guid ReferenceUserId { get; set; }
public string ReferenceUserId { get; set; }
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
public ConcurrentDictionary<Guid, PartyMember> Members { get; } = new ConcurrentDictionary<Guid, PartyMember>();
public ConcurrentDictionary<string, PartyMember> Members { get; } = new ConcurrentDictionary<string, PartyMember>();
public DateTime LastActivityUtc { get; set; } = DateTime.UtcNow;
}

View file

@ -21,7 +21,7 @@ namespace WatchParty.Parties
private readonly ConcurrentDictionary<string, Guid> _partyIdByJoinCode =
new ConcurrentDictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
public Party Create(Guid hostUserId, string hostUserName, string hostSessionId, Guid itemId)
public Party Create(string hostUserId, string hostUserName, string hostSessionId, string itemId)
{
var party = new Party
{
@ -61,7 +61,7 @@ namespace WatchParty.Parties
return null;
}
public PartyMember Join(Party party, Guid userId, string userName, string sessionId)
public PartyMember Join(Party party, string userId, string userName, string sessionId)
{
var member = new PartyMember
{
@ -77,7 +77,7 @@ namespace WatchParty.Parties
return member;
}
public void Leave(Party party, Guid userId)
public void Leave(Party party, string userId)
{
party.Members.TryRemove(userId, out _);
party.LastActivityUtc = DateTime.UtcNow;
@ -87,7 +87,7 @@ namespace WatchParty.Parties
// Reference left: fall back to whoever's left (arbitrary but
// deterministic-ish; good enough for v1 - see docs/architecture.md).
var next = party.Members.Keys.FirstOrDefault();
if (next != Guid.Empty)
if (next != null)
{
party.ReferenceUserId = next;
}

View file

@ -4,7 +4,8 @@ namespace WatchParty.Parties
{
public class PartyMember
{
public Guid UserId { get; set; }
/// <summary>Emby user id - a plain string on this SDK version, not a Guid.</summary>
public string UserId { get; set; }
public string UserName { get; set; }

View file

@ -9,22 +9,13 @@ using WatchParty.Configuration;
namespace WatchParty
{
// -----------------------------------------------------------------------
// Emby plugin entry point.
//
// Confirmed against Emby's own docs while writing this scaffold:
// - BasePlugin<T> + constructor shape:
// https://github.com/MediaBrowser/Emby/wiki/How-to-build-a-Server-Plugin
// - netstandard2.0 target / MediaBrowser.Server.Core package: same page.
//
// NOT independently confirmed (this project targets Emby Server 4.9.5.0;
// the wiki examples above are older) - check these once you can
// restore/build against the real SDK assemblies. Full list in
// docs/architecture.md#assumptions-to-verify:
// - IHasWebPages is assumed to be the current interface name for a
// dashboard settings page. At least one community plugin
// (emby-playlist-manager) uses IHasUIPages instead, which may be the
// older name.
// - PluginPageInfo's exact property names (Name / EmbeddedResourcePath).
// Emby plugin entry point. Builds clean against the real
// MediaBrowser.Server.Core/MediaBrowser.Common 4.9.1.90 assemblies
// (restored from NuGet) - BasePlugin<T>, IHasWebPages and PluginPageInfo's
// Name/EmbeddedResourcePath are all confirmed as used below. See
// docs/architecture.md#assumptions-to-verify for what's still only
// compile-checked rather than run against a real server (e.g. whether the
// dashboard actually renders this settings page correctly).
// -----------------------------------------------------------------------
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{

View file

@ -1,5 +1,6 @@
using System;
using System.Threading;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Plugins;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Logging;
@ -58,14 +59,14 @@ namespace WatchParty.Sync
private void UpdateMemberState(SessionInfo session)
{
if (session?.UserId == null || session.PlayState == null)
if (string.IsNullOrEmpty(session?.UserId) || session.PlayState == null)
{
return;
}
foreach (var party in PartyManager.Instance.GetAllActive())
{
if (party.Members.TryGetValue(session.UserId.Value, out var member) &&
if (party.Members.TryGetValue(session.UserId, out var member) &&
string.Equals(member.SessionId, session.Id, StringComparison.Ordinal))
{
member.LastKnownPositionTicks = session.PlayState.PositionTicks ?? member.LastKnownPositionTicks;