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 ## Status
Early scaffold, not yet built or run against a real server. It's structured Builds clean (0 errors, 0 warnings) against the real `MediaBrowser.Server.Core`
around Emby's documented plugin patterns (`BasePlugin<T>`, `IServerEntryPoint`, / `MediaBrowser.Common` 4.9.1.90 assemblies from NuGet. Not yet installed or
the `IService`/`[Route]` API style) and the confirmed run against an actual Emby Server — a few things (auth header format, HTTP
`ISessionManager.SendPlaystateCommand` remote-control API, but several SDK status codes, the dashboard settings page rendering) are only compile-checked
details are marked `TODO(verify)` in the code — see so far, not runtime-verified. See
[docs/architecture.md](docs/architecture.md#assumptions-to-verify) before [docs/architecture.md](docs/architecture.md#assumptions-to-verify) for
expecting it to compile as-is. exactly what was wrong on the first guess, what was fixed, and what's still
open.
## Requirements ## Requirements
- Emby Server (developed against 4.9.5.0; should work on nearby 4.9.x/4.8.x - 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) builds, but the plugin ABI does shift between versions)
- [.NET SDK](https://dotnet.microsoft.com/download) to build (not currently - [.NET SDK](https://dotnet.microsoft.com/download) 8.0 to build (installed
installed in this environment — `dotnet --version` came back empty) 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 ## Project layout
@ -74,7 +77,7 @@ portable install vs. Docker volume).
## Roadmap ## Roadmap
- [x] Design doc + scaffold - [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 - [ ] Try it against a real Emby Server + two clients
- [ ] Live (WebSocket) updates in the control panel instead of polling - [ ] Live (WebSocket) updates in the control panel instead of polling
- [ ] Optional text chat, reusing the same realtime channel - [ ] 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 ## Assumptions to verify
This was designed by reading Emby's public docs/wiki and a couple of Update: the .NET SDK is now installed and `src/WatchParty` **builds clean
community plugins (linked above), without a local copy of the SDK to compile (0 errors, 0 warnings) against the real `MediaBrowser.Server.Core` /
against (no .NET SDK was installed in the environment this was scaffolded in, `MediaBrowser.Common` 4.9.1.90 assemblies**, restored from NuGet. Most of the
and the exact `MediaBrowser.Server.Core` build for Emby Server 4.9.5.0 wasn't list below turned out right on the first guess; a few didn't, and were fixed
available to inspect directly). Treat the overall shape as solid and these by reading the actual assemblies (via .NET reflection) and then letting the
specifics as needing a compile-and-fix pass: 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 **Confirmed correct as originally written** (compiled with no changes needed):
different names depending on SDK era; `Plugin.cs` assumes `IHasWebPages`. - `BasePlugin<T>` constructor shape, `IServerEntryPoint`, `netstandard2.0` +
- **`PluginPageInfo` property names** (`Name`, `EmbeddedResourcePath`). `MediaBrowser.Server.Core` package — all from the official wiki.
- **ID types** — whether item/user/session IDs are `Guid` or `string` on your - `IHasWebPages` (not `IHasUIPages` — that name does exist too, in
SDK version (this has changed across Emby versions). `Party`/`PartyMember` `MediaBrowser.Model.Plugins.UI`, but `IHasWebPages` in
assume `Guid`. `MediaBrowser.Model.Plugins` is the one that's actually wired up here) and
- **How an `IService` reads the calling user** — `WatchPartyService` assumes `PluginPageInfo.Name`/`.EmbeddedResourcePath`.
constructor-injected `IAuthorizationContext` + `IRequiresRequest.Request`, - `ISessionManager.SendPlaystateCommand(string controllingSessionId, string
then `GetAuthorizationInfo(Request).UserId`. sessionId, PlaystateRequest, CancellationToken)`, `SendGeneralCommand`, and
- **HTTP error mapping** — endpoints currently throw plain `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 `ArgumentException`/`InvalidOperationException`; Emby likely has a more
specific exception type (something like `ResourceNotFoundException`) that specific exception type that maps to a proper 404/400 instead of a
maps to a proper 404/400 instead of a generic 500. generic 500.
- **Static file content types** — `GET /WatchParty/app` etc. currently return - Static file content types — `GET /WatchParty/app` etc. return a bare
a bare string. The wiki confirms an `IHasResultFactory` extension point string; `IHasResultFactory` looks like the right extension point for a
exists for setting a real `Content-Type`/caching headers; wiring that up is real `Content-Type`/caching headers, not wired up yet.
a follow-up rather than done here. - `controllingSessionId` passed as `null` to `SendPlaystateCommand` — compiles,
- **Logging API shape** — `ILogManager.GetLogger(name)` / unknown whether Emby's runtime wants a real session/user there instead.
`ILogger.Debug(...)` / `.ErrorException(...)` is the classic - `X-Emby-Authorization` header fields and `/Users/AuthenticateByName`'s `Pw`
Emby/MediaBrowser pattern; some SDK generations may expect field name in `web/control-panel/app.js` — it's JS, nothing compiles it;
`Microsoft.Extensions.Logging` instead. long-standing Emby/Jellyfin convention, not exercised against a real server.
- **`X-Emby-Authorization` header fields and `/Users/AuthenticateByName`'s - The dashboard settings page (`configPage.html`) actually rendering/saving
`Pw` field name** in `web/control-panel/app.js` — long-standing Emby/ correctly — embedding compiles, but the page's own JS (`ApiClient`,
Jellyfin convention, not re-verified against 4.9.5.0 specifically. `Dashboard` globals) only exists inside a running Emby dashboard.
The fastest way to nail all of these down: install the .NET SDK, `dotnet The remaining way to close these out: install the plugin DLL on a real Emby
restore` the project (pulls the real `MediaBrowser.Server.Core` / Server 4.9.x and exercise it with two accounts.
`MediaBrowser.Common` assemblies from NuGet), and let the compiler point at
whatever doesn't match.
## Known limitations ## Known limitations

View file

@ -2,6 +2,7 @@ using System;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session; using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Services; using MediaBrowser.Model.Services;
using MediaBrowser.Model.Session; using MediaBrowser.Model.Session;
@ -10,15 +11,23 @@ using WatchParty.Parties;
namespace WatchParty.Api namespace WatchParty.Api
{ {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// The [Route]/IService/IReturn<T> pattern here is confirmed against Emby's // Builds clean against the real MediaBrowser.Server.Core/MediaBrowser.Common
// official wiki: https://github.com/MediaBrowser/Emby/wiki/Creating-Api-Endpoints // 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: // Still only compile-checked, not run against a real server - see
// - How a service reads the calling user (assumed here: constructor-injected // docs/architecture.md#assumptions-to-verify:
// IAuthorizationContext + IRequiresRequest.Request, then
// GetAuthorizationInfo(Request).UserId).
// - Exact HTTP-error exception types (using plain ArgumentException / // - 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 // - Content-Type on the static file responses (returning bare strings for
// now; IHasResultFactory looks like the right extension point later). // now; IHasResultFactory looks like the right extension point later).
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -71,15 +80,15 @@ namespace WatchParty.Api
{ {
public Guid PartyId { get; set; } public Guid PartyId { get; set; }
public string JoinCode { get; set; } public string JoinCode { get; set; }
public Guid ItemId { get; set; } public string ItemId { get; set; }
public Guid HostUserId { get; set; } public string HostUserId { get; set; }
public Guid ReferenceUserId { get; set; } public string ReferenceUserId { get; set; }
public PartyMemberDto[] Members { get; set; } public PartyMemberDto[] Members { get; set; }
} }
public class PartyMemberDto public class PartyMemberDto
{ {
public Guid UserId { get; set; } public string UserId { get; set; }
public string UserName { get; set; } public string UserName { get; set; }
public bool IsPaused { get; set; } public bool IsPaused { get; set; }
public long PositionTicks { get; set; } public long PositionTicks { get; set; }
@ -175,12 +184,21 @@ namespace WatchParty.Api
public object Get(GetControlPanelCss request) => ReadEmbedded("app.css"); 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 var candidate = _sessionManager.Sessions
.Where(s => s.UserId == userId && s.NowPlayingItem != null) .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> /// <summary>Short human-typeable code used to join, e.g. "K7QX2P".</summary>
public string JoinCode { get; set; } public string JoinCode { get; set; }
/// <summary>The library item everyone is meant to be watching.</summary> /// <summary>The library item everyone is meant to be watching. Emby's own
public Guid ItemId { get; set; } /// 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> /// <summary>
/// Whose reported position is treated as ground truth. Defaults to the /// Whose reported position is treated as ground truth. Defaults to the
/// host, but falls back to whoever's left if the host leaves (see /// host, but falls back to whoever's left if the host leaves (see
/// PartyManager.Leave). /// PartyManager.Leave).
/// </summary> /// </summary>
public Guid ReferenceUserId { get; set; } public string ReferenceUserId { get; set; }
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; 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; public DateTime LastActivityUtc { get; set; } = DateTime.UtcNow;
} }

View file

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

View file

@ -4,7 +4,8 @@ namespace WatchParty.Parties
{ {
public class PartyMember 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; } public string UserName { get; set; }

View file

@ -9,22 +9,13 @@ using WatchParty.Configuration;
namespace WatchParty namespace WatchParty
{ {
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Emby plugin entry point. // Emby plugin entry point. Builds clean against the real
// // MediaBrowser.Server.Core/MediaBrowser.Common 4.9.1.90 assemblies
// Confirmed against Emby's own docs while writing this scaffold: // (restored from NuGet) - BasePlugin<T>, IHasWebPages and PluginPageInfo's
// - BasePlugin<T> + constructor shape: // Name/EmbeddedResourcePath are all confirmed as used below. See
// https://github.com/MediaBrowser/Emby/wiki/How-to-build-a-Server-Plugin // docs/architecture.md#assumptions-to-verify for what's still only
// - netstandard2.0 target / MediaBrowser.Server.Core package: same page. // compile-checked rather than run against a real server (e.g. whether the
// // dashboard actually renders this settings page correctly).
// 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).
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{ {

View file

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