From b83074d55fc709b6e4bd9afb672c65ebd34fcce7 Mon Sep 17 00:00:00 2001 From: seer Date: Thu, 9 Jul 2026 17:06:46 +0200 Subject: [PATCH] Add SyncPlay stats-for-nerds page, manual sync button, and release pipeline Phase 0 scaffold plus everything since: quality negotiation, host-relay, and active drift correction were each built and reverted after real bugs in live use (see PLAN.md for the full account of each). What remains is read-only SyncPlay observability (live drift/playback stats page) and a one-shot manual "sync me to group" action, plus Phase 5 packaging/release tooling (build.yaml, manifest.json generator, Gitea Actions release workflow) to distribute it as a proper plugin repository. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/build.yml | 29 + .github/workflows/release.yml | 90 +++ .gitignore | 6 + JellyfinSyncPlus.sln | 27 + PLAN.md | 199 ++++++- build.yaml | 21 + dev/docker-compose.yml | 18 + dev/extract-private-refs.sh | 15 + dev/package-release.sh | 48 ++ .../JellyfinSyncPlus_0.1.0.0/meta.json | 13 + dev/update_manifest.py | 85 +++ manifest.json | 20 + .../Configuration/PluginConfiguration.cs | 8 + .../Configuration/configPage.html | 19 + src/JellyfinSyncPlus/JellyfinSyncPlus.csproj | 48 ++ src/JellyfinSyncPlus/Plugin.cs | 54 ++ .../PluginServiceRegistrator.cs | 24 + .../SyncPlay/GroupMembershipStore.cs | 90 +++ .../SyncPlay/IGroupMembershipStore.cs | 41 ++ .../SyncPlay/SyncPlusStatsController.cs | 515 ++++++++++++++++++ .../SyncPlay/SyncPlusSyncPlayManager.cs | 86 +++ 21 files changed, 1435 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 JellyfinSyncPlus.sln create mode 100644 build.yaml create mode 100644 dev/docker-compose.yml create mode 100755 dev/extract-private-refs.sh create mode 100755 dev/package-release.sh create mode 100644 dev/plugins/JellyfinSyncPlus_0.1.0.0/meta.json create mode 100755 dev/update_manifest.py create mode 100644 manifest.json create mode 100644 src/JellyfinSyncPlus/Configuration/PluginConfiguration.cs create mode 100644 src/JellyfinSyncPlus/Configuration/configPage.html create mode 100644 src/JellyfinSyncPlus/JellyfinSyncPlus.csproj create mode 100644 src/JellyfinSyncPlus/Plugin.cs create mode 100644 src/JellyfinSyncPlus/PluginServiceRegistrator.cs create mode 100644 src/JellyfinSyncPlus/SyncPlay/GroupMembershipStore.cs create mode 100644 src/JellyfinSyncPlus/SyncPlay/IGroupMembershipStore.cs create mode 100644 src/JellyfinSyncPlus/SyncPlay/SyncPlusStatsController.cs create mode 100644 src/JellyfinSyncPlus/SyncPlay/SyncPlusSyncPlayManager.cs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..8dadb1a --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,29 @@ +name: Build Check + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup .NET + uses: https://github.com/actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + # Same private-assembly fetch as release.yml -- see PLAN.md "Known friction / risks". + - name: Fetch private SDK references + run: | + mkdir -p lib/jellyfin-10.11.6 + docker create --name jf-extract linuxserver/jellyfin:10.11.6 + docker cp jf-extract:/usr/lib/jellyfin/bin/Emby.Server.Implementations.dll lib/jellyfin-10.11.6/ + docker rm jf-extract + + - name: Build + run: dotnet build src/JellyfinSyncPlus/JellyfinSyncPlus.csproj -c Release diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..241c67b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,90 @@ +name: Release Plugin + +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout tagged commit + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: https://github.com/actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: metadata + id: meta + run: | + echo REPO_OWNER=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $1}') >> $GITHUB_OUTPUT + echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT + echo VERSION=$(echo ${GITHUB_REF_NAME} | sed 's/^v//') >> $GITHUB_OUTPUT + cat $GITHUB_OUTPUT + + # Fetch the two private (non-NuGet) server assemblies our plugin needs a + # compile-time-only reference to -- see PLAN.md "Known friction / risks" and + # dev/extract-private-refs.sh, which this mirrors for CI instead of a local + # docker container. + - name: Fetch private SDK references + run: | + mkdir -p lib/jellyfin-10.11.6 + docker create --name jf-extract linuxserver/jellyfin:10.11.6 + docker cp jf-extract:/usr/lib/jellyfin/bin/Emby.Server.Implementations.dll lib/jellyfin-10.11.6/ + docker rm jf-extract + + - name: Build, package, update manifest + env: + VERSION: ${{ steps.meta.outputs.VERSION }} + SERVER_URL: ${{ github.server_url }} + REPO_OWNER: ${{ steps.meta.outputs.REPO_OWNER }} + REPO_NAME: ${{ steps.meta.outputs.REPO_NAME }} + run: dev/package-release.sh + + - name: Create Gitea release and upload zip + env: + GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }} + SERVER_URL: ${{ github.server_url }} + REPO_OWNER: ${{ steps.meta.outputs.REPO_OWNER }} + REPO_NAME: ${{ steps.meta.outputs.REPO_NAME }} + VERSION: ${{ steps.meta.outputs.VERSION }} + TAG: ${{ github.ref_name }} + run: | + RELEASE_ID=$(curl -sf -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"body\":\"See build.yaml changelog / manifest.json for this version's notes.\"}" \ + "${SERVER_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])') + curl -sf -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -F "attachment=@release/JellyfinSyncPlus_${VERSION}.zip" \ + "${SERVER_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=JellyfinSyncPlus_${VERSION}.zip" + + # manifest.json has to live at a stable URL on a normal branch (master) -- + # that's the one URL admins add to Jellyfin once, and every future release + # just appends to it. The tag checkout above is detached HEAD, so switch to + # master to commit, carrying the just-updated manifest.json across. + - name: Commit updated manifest to master + env: + GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }} + SERVER_URL: ${{ github.server_url }} + REPO_OWNER: ${{ steps.meta.outputs.REPO_OWNER }} + REPO_NAME: ${{ steps.meta.outputs.REPO_NAME }} + VERSION: ${{ steps.meta.outputs.VERSION }} + run: | + cp manifest.json /tmp/manifest.json + git fetch origin master + git checkout master + cp /tmp/manifest.json manifest.json + git config user.name "gitea-actions" + git config user.email "actions@gitea.local" + git add manifest.json + git diff --cached --quiet && echo "No manifest changes to commit" && exit 0 + git commit -m "Release ${VERSION}" + HOST=$(echo "${SERVER_URL}" | sed 's#https\?://##') + git push "https://gitea-actions:${GITEA_TOKEN}@${HOST}/${REPO_OWNER}/${REPO_NAME}.git" HEAD:master diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d06dbe --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +bin/ +obj/ +dev/config/ +dev/plugins/*/*.dll +lib/ +release/ diff --git a/JellyfinSyncPlus.sln b/JellyfinSyncPlus.sln new file mode 100644 index 0000000..d62bae0 --- /dev/null +++ b/JellyfinSyncPlus.sln @@ -0,0 +1,27 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{B9665269-901B-4DDD-8868-90FF6DA29CAE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JellyfinSyncPlus", "src\JellyfinSyncPlus\JellyfinSyncPlus.csproj", "{FEEC1A71-0966-43BD-8400-F9081DB75CB0}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {FEEC1A71-0966-43BD-8400-F9081DB75CB0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FEEC1A71-0966-43BD-8400-F9081DB75CB0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FEEC1A71-0966-43BD-8400-F9081DB75CB0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FEEC1A71-0966-43BD-8400-F9081DB75CB0}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {FEEC1A71-0966-43BD-8400-F9081DB75CB0} = {B9665269-901B-4DDD-8868-90FF6DA29CAE} + EndGlobalSection +EndGlobal diff --git a/PLAN.md b/PLAN.md index 024a8b8..b70c606 100644 --- a/PLAN.md +++ b/PLAN.md @@ -32,6 +32,19 @@ switching tools. ## Architecture -- three pillars +**Status note (2026-07-09):** all three pillars below were built and then +reverted after real bugs/unwanted behavior in live use (see "Rough phase +breakdown" for the full account of each). Pillars 1/2: forcing quality/path +alignment through the `PlaybackInfo` request froze clients on quality +switches and left them permanently desynced. Pillar 3: a scoped-down active +seek-correction on top of stock Jellyfin's own (already quite good) client +correction caused its own feedback-loop problems and, even fixed, wasn't +what the user wanted in practice. The original rationale is kept below for +context, but as of this note **the plugin has no active functionality** -- +it's back to the Phase 0 scaffold baseline. Read "Rough phase breakdown" for +what's actually true today, and don't restart from these designs without +addressing why each one broke. + ### 1. Group quality negotiation (the easy piece) On group formation, resolve each member's device capability/bitrate profile @@ -149,37 +162,181 @@ Two different loops -- do not conflate them: - **ABI/version pinning**: `meta.json`'s `targetAbi` must match the running server version. Already fine since the deployment is pinned to `10.11.6`, not floating -- build against that exact version's SDK. -- **Reaches past the stable plugin API.** SyncPlay group internals and - transcode-session state aren't part of Jellyfin's published plugin SDK - surface. Realistically requires building against Jellyfin server source at - the matching tag (`git` checkout/submodule of `jellyfin/jellyfin` @ - `v10.11.6`) rather than just NuGet packages. This is closer to - "semi-fork with a plugin wrapper" than a typical plugin -- sizing decisions - should account for that. +- **Reaches past the stable plugin API -- narrower than originally assumed.** + Verified during Phase 1: `ISyncPlayManager`, `IGroupStateContext`, + `GroupInfoDto`, and all the `IGroupPlaybackRequest` DTOs *are* public in the + `Jellyfin.Controller` NuGet package -- group internals aren't actually + hidden. What *is* missing from the SDK is the concrete implementations: + `Emby.Server.Implementations.SyncPlay.SyncPlayManager` (needed to decorate + `ISyncPlayManager` without reimplementing group logic) and + `Jellyfin.Api.Controllers.MediaInfoController` / + `Jellyfin.Api.Models.MediaInfoDtos.PlaybackInfoDto` (needed to target the + PlaybackInfo action with an MVC filter). Both are public classes but ship + only inside the server image, not as NuGet packages. Working solution: + extract the two DLLs straight from the pinned `linuxserver/jellyfin:10.11.6` + image (`docker cp :/usr/lib/jellyfin/bin/{Emby.Server.Implementations,Jellyfin.Api}.dll`) + into `lib/jellyfin-10.11.6/` and reference them as `` + -- compile-time only, since they're already loaded in the host process at + runtime. No source checkout/submodule needed after all; re-extract these two + files if the pinned server version ever changes. See `src/JellyfinSyncPlus/SyncPlay/` + for the resulting decorator (`SyncPlusSyncPlayManager`) and MVC filter + (`SyncPlusPlaybackInfoFilter`). - Host-relay approach (pillar 2) means the "host" member is a single point of failure for the group's stream -- needs a defined handoff/reselection behavior if the host disconnects mid-session (not just late-joiners). ## Rough phase breakdown -- **Phase 0 -- scaffold & dev loop.** `dotnet` plugin project scaffold against - `Jellyfin.Controller` (or a source checkout of `jellyfin/jellyfin@v10.11.6` - if the public SDK isn't sufficient), local docker dev Jellyfin with - bind-mounted plugins folder, confirm a trivial "hello world" plugin loads - and shows up in Dashboard -> Plugins. -- **Phase 1 -- group quality negotiation.** Hook group-join, resolve each - member's device profile, compute the common lowest profile, verify against - real device/browser combos. -- **Phase 2 -- host-relay shared transcode.** Custom API route serving the - host session's HLS playlist/segments to other members; handle host - disconnect/reselection. -- **Phase 3 -- active drift correction.** Virtual playhead, clock calibration, - heartbeat + EMA, two-tier nudge/reseek correction, scheduled absolute-time - commands. +- **Phase 0 -- scaffold & dev loop. DONE.** `dotnet` plugin project scaffold + against `Jellyfin.Controller`/`Jellyfin.Model` 10.11.6 (public SDK was + sufficient for this trivial plugin), local docker dev Jellyfin + (`linuxserver/jellyfin:10.11.6`) with bind-mounted plugins folder, confirmed + via container logs: `Loaded plugin: JellyfinSyncPlus 0.1.0.0`. Gotcha + discovered: in `linuxserver/jellyfin`, the real plugins directory is + `/config/data/plugins`, **not** `/config/plugins` (the latter exists but is + unused/dead) -- `dev/docker-compose.yml` bind-mounts to the correct path + now, don't regress this on a future compose edit. +- **Phase 1 -- group quality negotiation. REVERTED 2026-07-09, superseded by + Phase 3's approach below.** Built and live-verified (bitrate cap negotiation + via `GroupQualityStore`/`SyncPlusPlaybackInfoFilter`, working correctly + against real multi-device sessions -- see git history for the full account + if this needs resurrecting). Torn out after a real, serious bug surfaced in + actual use: switching quality mid-playback forces Jellyfin to issue a new + `PlaySessionId` and reinitialize the player; our filter mutating that + request made the client freeze on the switch and then **never resync + afterward** -- worse than the drift it was meant to prevent. Root problem + wasn't the negotiation math, it was forcing anything through the + `PlaybackInfo` request/response path at all, since Jellyfin has no seam + there for a plugin to intervene without looking, to the client, exactly + like a user-initiated quality change. Replaced by active, corrective + drift-correction instead of preventive path-forcing (Phase 3). +- **Phase 2 -- host-relay shared transcode. REVERTED 2026-07-09, same + reason as Phase 1** (the relay redirect went through the same + `PlaybackInfo` response mutation that caused the freeze/permanent-desync + bug). The engineering was sound and fully live-verified end-to-end + against real video (real bug found and fixed along the way: browsers + fetch HLS playlist/segment URLs with no auth header, only Jellyfin's + `ApiKey=` query-param convention, which the relay didn't originally + include) -- but it's not worth resurrecting unless a future need + specifically requires deduplicating transcode load across a group, since + Phase 3's correction now handles the drift problem this was meant to + solve, without the instability. See git history for the implementation if + transcode load dedup becomes a real requirement later. +- **Phase 3 -- active drift correction. TRIED, REVERTED 2026-07-09.** Built a + scoped-down version of this section's original plan (server watches + standard `PlaybackProgress` reports, seeks back any member who drifts more + than 3 seconds ahead of the group's laggard, one-directional only, plus a + toast notification -- all via existing Jellyfin mechanisms, no custom + client). Confirmed along the way that stock Jellyfin's web client already + ships real NTP-style clock sync and two-tier correction + (`syncPlay-core-PlaybackCore.*.chunk.js`, `SpeedToSync`/`SkipToSync`, with + a real settings UI) and keeps steady-state drift bounded to ~70-340ms on + its own -- that finding still stands and is worth remembering if this gets + revisited. But the *active* correction on top of it didn't hold up in + real use: a manual timeline seek triggered a feedback loop (fixed once -- + needed a per-device cooldown and to stop trusting an optimistically- + written position as the "laggard" reference -- see git history for the + full bug writeup) and even after that fix the user's call was that the + behavior still wasn't right in practice. Pulled entirely rather than + chase it further: `SyncPlusDriftCorrector`, `SyncPlusSyncPlayManager`, + `IGroupMembershipStore`/`GroupMembershipStore`, and the + `Emby.Server.Implementations.dll` reference they needed are all gone. The + plugin is back to the Phase 0 baseline: loads, does nothing. All three + original pillars (quality negotiation, host-relay, active correction) + have now been tried and reverted -- see git history for any of them if a + future direction wants to resurrect pieces, but don't restart from these + designs without addressing why each one broke in real use (see the + Phase 1/2 entries above and this one). - **Phase 4 -- disconnects, late joiners, seek/pause polish.** - **Phase 5 -- packaging & release pipeline.** Gitea-hosted plugin repository manifest, versioned zip releases, mirroring MR-Discord's pipeline. +## Stats for nerds (added 2026-07-09, outside the original three-pillar scope) + +**DONE, live-verified.** After all three active-intervention pillars got +reverted, the next thing built was purely observational instead: a +YouTube-"stats for nerds"-style live view of every SyncPlay group's members, +their drift relative to each other, and their playback method/bitrate/codec. +Deliberately has zero effect on playback -- it only reads +`ISessionManager.Sessions` live on each request, no in-memory state, no +corrective action -- so it can't have the feedback-loop or freeze failure +modes the reverted features did. + +- `SyncPlusStatsController` (`GET /SyncPlus/Stats`, admin-only via + `MediaBrowser.Common.Api.Policies.RequiresElevation`) returns a live JSON + snapshot: for each SyncPlay group (from the passive `IGroupMembershipStore`/ + `SyncPlusSyncPlayManager` tracking -- same decorator pattern as before, + minus anything that mutates a request or issues a command), each member's + device/user name, now-playing item, position, drift in ms versus the + group's laggard, pause state, play method, and transcode + bitrate/codec/container/reason. +- **Real blocker found and worked around**: originally built as an + interactive plugin config page (`configPage.html`, reachable from + Dashboard -> Plugins), but its inline ` + + +"""; + + /// + /// Result of a call. + /// + public class SyncResultDto + { + /// + /// Gets or sets a human-readable description of what happened. + /// + public string Message { get; set; } = string.Empty; + + /// + /// Gets or sets how far back (in ms) the caller's session was moved. 0 if nothing + /// happened. + /// + public long MovedMs { get; set; } + } + + /// + /// Stats for one SyncPlay group. + /// + public class GroupStatsDto + { + /// + /// Gets or sets the SyncPlay group id. + /// + public Guid GroupId { get; set; } + + /// + /// Gets or sets the group's members. + /// + public IReadOnlyList Members { get; set; } = Array.Empty(); + } + + /// + /// Stats for one SyncPlay group member. + /// + public class MemberStatsDto + { + /// + /// Gets or sets the device id. + /// + public string DeviceId { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether this device still has a live session + /// (a device can remain group-tracked briefly after its session drops). + /// + public bool Connected { get; set; } + + /// + /// Gets or sets the device name. + /// + public string? DeviceName { get; set; } + + /// + /// Gets or sets the Jellyfin username. + /// + public string? UserName { get; set; } + + /// + /// Gets or sets the name of the item currently playing. + /// + public string? NowPlaying { get; set; } + + /// + /// Gets or sets the raw playback position in ticks. + /// + public long? PositionTicks { get; set; } + + /// + /// Gets or sets the playback position formatted as hh:mm:ss. + /// + public string? PositionFormatted { get; set; } + + /// + /// Gets or sets how far ahead (in ms) this member is of the group's laggard. + /// Never negative -- the laggard itself reports 0. + /// + public long? DriftMs { get; set; } + + /// + /// Gets or sets a value indicating whether playback is paused. + /// + public bool? IsPaused { get; set; } + + /// + /// Gets or sets the playback method (DirectPlay, DirectStream, Transcode). + /// + public string? PlayMethod { get; set; } + + /// + /// Gets or sets the transcoding bitrate in bits/sec, if transcoding. + /// + public int? Bitrate { get; set; } + + /// + /// Gets or sets the video codec being sent to this client. + /// + public string? VideoCodec { get; set; } + + /// + /// Gets or sets the audio codec being sent to this client. + /// + public string? AudioCodec { get; set; } + + /// + /// Gets or sets the output container. + /// + public string? Container { get; set; } + + /// + /// Gets or sets a value indicating whether the video stream is being copied + /// untouched (not re-encoded). + /// + public bool? IsVideoDirect { get; set; } + + /// + /// Gets or sets a value indicating whether the audio stream is being copied + /// untouched (not re-encoded). + /// + public bool? IsAudioDirect { get; set; } + + /// + /// Gets or sets why this client is transcoding, if it is. + /// + public string? TranscodeReasons { get; set; } + } +} diff --git a/src/JellyfinSyncPlus/SyncPlay/SyncPlusSyncPlayManager.cs b/src/JellyfinSyncPlus/SyncPlay/SyncPlusSyncPlayManager.cs new file mode 100644 index 0000000..e68508f --- /dev/null +++ b/src/JellyfinSyncPlus/SyncPlay/SyncPlusSyncPlayManager.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using Emby.Server.Implementations.SyncPlay; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay; +using MediaBrowser.Controller.SyncPlay.Requests; +using MediaBrowser.Model.SyncPlay; + +namespace Jellyfin.Plugin.SyncPlus.SyncPlay; + +/// +/// Decorates the built-in to track group membership by +/// device id, so knows which live sessions to +/// group together when reporting stats. Purely observational: every call is forwarded +/// to the real manager completely unmodified, nothing here ever mutates a +/// request or return value. Registered in place of the stock +/// via -- last DI registration for a service type +/// wins, and plugin service registration runs after core's, so this takes over cleanly +/// without touching server source. +/// +public class SyncPlusSyncPlayManager : ISyncPlayManager +{ + private readonly SyncPlayManager _inner; + private readonly IGroupMembershipStore _membershipStore; + + /// + /// Initializes a new instance of the class. + /// + /// The real, built-in SyncPlay manager this decorates. + /// Tracks group membership. + public SyncPlusSyncPlayManager(SyncPlayManager inner, IGroupMembershipStore membershipStore) + { + _inner = inner; + _membershipStore = membershipStore; + } + + /// + public GroupInfoDto NewGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken) + { + var result = _inner.NewGroup(session, request, cancellationToken); + _membershipStore.SessionJoined(result.GroupId, session.DeviceId); + return result; + } + + /// + public void JoinGroup(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken) + { + _inner.JoinGroup(session, request, cancellationToken); + _membershipStore.SessionJoined(request.GroupId, session.DeviceId); + } + + /// + public void LeaveGroup(SessionInfo session, LeaveGroupRequest request, CancellationToken cancellationToken) + { + var priorGroup = _membershipStore.GetGroupForDevice(session.DeviceId); + _inner.LeaveGroup(session, request, cancellationToken); + if (priorGroup.HasValue) + { + _membershipStore.SessionLeft(priorGroup.Value, session.DeviceId); + } + } + + /// + public List ListGroups(SessionInfo session, ListGroupsRequest request) + => _inner.ListGroups(session, request); + + /// + public GroupInfoDto GetGroup(SessionInfo session, Guid groupId) + => _inner.GetGroup(session, groupId); + + /// + public void HandleRequest(SessionInfo session, IGroupPlaybackRequest request, CancellationToken cancellationToken) + => _inner.HandleRequest(session, request, cancellationToken); + + /// + public bool IsUserActive(Guid userId) + => _inner.IsUserActive(userId); + + /// + public void Dispose() + { + _inner.Dispose(); + GC.SuppressFinalize(this); + } +}