diff --git a/README.md b/README.md index 743b38c..f065051 100644 --- a/README.md +++ b/README.md @@ -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`, `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 diff --git a/docs/architecture.md b/docs/architecture.md index 0e1976d..6238de6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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` 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` 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 diff --git a/src/WatchParty/Api/WatchPartyService.cs b/src/WatchParty/Api/WatchPartyService.cs index 09cae1a..ec6bf64 100644 --- a/src/WatchParty/Api/WatchPartyService.cs +++ b/src/WatchParty/Api/WatchPartyService.cs @@ -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 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 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) diff --git a/src/WatchParty/Parties/Party.cs b/src/WatchParty/Parties/Party.cs index 74ae46a..a0e3951 100644 --- a/src/WatchParty/Parties/Party.cs +++ b/src/WatchParty/Parties/Party.cs @@ -14,21 +14,24 @@ namespace WatchParty.Parties /// Short human-typeable code used to join, e.g. "K7QX2P". public string JoinCode { get; set; } - /// The library item everyone is meant to be watching. - public Guid ItemId { get; set; } + /// 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. + public string ItemId { get; set; } - public Guid HostUserId { get; set; } + public string HostUserId { get; set; } /// /// 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). /// - public Guid ReferenceUserId { get; set; } + public string ReferenceUserId { get; set; } public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; - public ConcurrentDictionary Members { get; } = new ConcurrentDictionary(); + public ConcurrentDictionary Members { get; } = new ConcurrentDictionary(); public DateTime LastActivityUtc { get; set; } = DateTime.UtcNow; } diff --git a/src/WatchParty/Parties/PartyManager.cs b/src/WatchParty/Parties/PartyManager.cs index 8d532f5..e4a770d 100644 --- a/src/WatchParty/Parties/PartyManager.cs +++ b/src/WatchParty/Parties/PartyManager.cs @@ -21,7 +21,7 @@ namespace WatchParty.Parties private readonly ConcurrentDictionary _partyIdByJoinCode = new ConcurrentDictionary(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; } diff --git a/src/WatchParty/Parties/PartyMember.cs b/src/WatchParty/Parties/PartyMember.cs index b48f1e0..00e2df2 100644 --- a/src/WatchParty/Parties/PartyMember.cs +++ b/src/WatchParty/Parties/PartyMember.cs @@ -4,7 +4,8 @@ namespace WatchParty.Parties { public class PartyMember { - public Guid UserId { get; set; } + /// Emby user id - a plain string on this SDK version, not a Guid. + public string UserId { get; set; } public string UserName { get; set; } diff --git a/src/WatchParty/Plugin.cs b/src/WatchParty/Plugin.cs index e45cd31..8ee5e06 100644 --- a/src/WatchParty/Plugin.cs +++ b/src/WatchParty/Plugin.cs @@ -9,22 +9,13 @@ using WatchParty.Configuration; namespace WatchParty { // ----------------------------------------------------------------------- - // Emby plugin entry point. - // - // Confirmed against Emby's own docs while writing this scaffold: - // - BasePlugin + 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, 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, IHasWebPages { diff --git a/src/WatchParty/Sync/PartySyncEntryPoint.cs b/src/WatchParty/Sync/PartySyncEntryPoint.cs index afff62e..8128592 100644 --- a/src/WatchParty/Sync/PartySyncEntryPoint.cs +++ b/src/WatchParty/Sync/PartySyncEntryPoint.cs @@ -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;