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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
dev/config/
|
||||||
|
dev/plugins/*/*.dll
|
||||||
|
lib/
|
||||||
|
release/
|
||||||
@@ -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
|
||||||
@@ -32,6 +32,19 @@ switching tools.
|
|||||||
|
|
||||||
## Architecture -- three pillars
|
## 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)
|
### 1. Group quality negotiation (the easy piece)
|
||||||
|
|
||||||
On group formation, resolve each member's device capability/bitrate profile
|
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
|
- **ABI/version pinning**: `meta.json`'s `targetAbi` must match the running
|
||||||
server version. Already fine since the deployment is pinned to `10.11.6`,
|
server version. Already fine since the deployment is pinned to `10.11.6`,
|
||||||
not floating -- build against that exact version's SDK.
|
not floating -- build against that exact version's SDK.
|
||||||
- **Reaches past the stable plugin API.** SyncPlay group internals and
|
- **Reaches past the stable plugin API -- narrower than originally assumed.**
|
||||||
transcode-session state aren't part of Jellyfin's published plugin SDK
|
Verified during Phase 1: `ISyncPlayManager`, `IGroupStateContext`,
|
||||||
surface. Realistically requires building against Jellyfin server source at
|
`GroupInfoDto`, and all the `IGroupPlaybackRequest` DTOs *are* public in the
|
||||||
the matching tag (`git` checkout/submodule of `jellyfin/jellyfin` @
|
`Jellyfin.Controller` NuGet package -- group internals aren't actually
|
||||||
`v10.11.6`) rather than just NuGet packages. This is closer to
|
hidden. What *is* missing from the SDK is the concrete implementations:
|
||||||
"semi-fork with a plugin wrapper" than a typical plugin -- sizing decisions
|
`Emby.Server.Implementations.SyncPlay.SyncPlayManager` (needed to decorate
|
||||||
should account for that.
|
`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 <container>:/usr/lib/jellyfin/bin/{Emby.Server.Implementations,Jellyfin.Api}.dll`)
|
||||||
|
into `lib/jellyfin-10.11.6/` and reference them as `<Reference Private="false">`
|
||||||
|
-- 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
|
- 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
|
failure for the group's stream -- needs a defined handoff/reselection
|
||||||
behavior if the host disconnects mid-session (not just late-joiners).
|
behavior if the host disconnects mid-session (not just late-joiners).
|
||||||
|
|
||||||
## Rough phase breakdown
|
## Rough phase breakdown
|
||||||
|
|
||||||
- **Phase 0 -- scaffold & dev loop.** `dotnet` plugin project scaffold against
|
- **Phase 0 -- scaffold & dev loop. DONE.** `dotnet` plugin project scaffold
|
||||||
`Jellyfin.Controller` (or a source checkout of `jellyfin/jellyfin@v10.11.6`
|
against `Jellyfin.Controller`/`Jellyfin.Model` 10.11.6 (public SDK was
|
||||||
if the public SDK isn't sufficient), local docker dev Jellyfin with
|
sufficient for this trivial plugin), local docker dev Jellyfin
|
||||||
bind-mounted plugins folder, confirm a trivial "hello world" plugin loads
|
(`linuxserver/jellyfin:10.11.6`) with bind-mounted plugins folder, confirmed
|
||||||
and shows up in Dashboard -> Plugins.
|
via container logs: `Loaded plugin: JellyfinSyncPlus 0.1.0.0`. Gotcha
|
||||||
- **Phase 1 -- group quality negotiation.** Hook group-join, resolve each
|
discovered: in `linuxserver/jellyfin`, the real plugins directory is
|
||||||
member's device profile, compute the common lowest profile, verify against
|
`/config/data/plugins`, **not** `/config/plugins` (the latter exists but is
|
||||||
real device/browser combos.
|
unused/dead) -- `dev/docker-compose.yml` bind-mounts to the correct path
|
||||||
- **Phase 2 -- host-relay shared transcode.** Custom API route serving the
|
now, don't regress this on a future compose edit.
|
||||||
host session's HLS playlist/segments to other members; handle host
|
- **Phase 1 -- group quality negotiation. REVERTED 2026-07-09, superseded by
|
||||||
disconnect/reselection.
|
Phase 3's approach below.** Built and live-verified (bitrate cap negotiation
|
||||||
- **Phase 3 -- active drift correction.** Virtual playhead, clock calibration,
|
via `GroupQualityStore`/`SyncPlusPlaybackInfoFilter`, working correctly
|
||||||
heartbeat + EMA, two-tier nudge/reseek correction, scheduled absolute-time
|
against real multi-device sessions -- see git history for the full account
|
||||||
commands.
|
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 4 -- disconnects, late joiners, seek/pause polish.**
|
||||||
- **Phase 5 -- packaging & release pipeline.** Gitea-hosted plugin repository
|
- **Phase 5 -- packaging & release pipeline.** Gitea-hosted plugin repository
|
||||||
manifest, versioned zip releases, mirroring MR-Discord's pipeline.
|
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 `<script>` never ran -- confirmed
|
||||||
|
live by inspecting the actual DOM after injection: 0 `<script>` elements
|
||||||
|
survived. Jellyfin's web client runs plugin config page HTML through
|
||||||
|
DOMPurify before injecting it into the SPA, which strips script tags
|
||||||
|
entirely (also confirmed the older `pageshow`/`pagehide` custom events
|
||||||
|
legacy plugin pages relied on aren't dispatched by the current
|
||||||
|
React-based client either -- tried that first, same silent no-op result).
|
||||||
|
No script tag survives that DOMPurify pass, regardless of what would have
|
||||||
|
triggered it.
|
||||||
|
- **Fix**: serve the interactive page as a genuine standalone HTML response
|
||||||
|
instead (`GET /SyncPlus/Stats/Page`, `[AllowAnonymous]` since the page
|
||||||
|
shell itself has nothing sensitive in it) -- a real page navigation isn't
|
||||||
|
run through the SPA's sanitizer, so inline `<script>` works normally
|
||||||
|
there. The page reads the current user's token straight out of the same
|
||||||
|
`jellyfin_credentials` localStorage entry the main web client itself uses
|
||||||
|
(same origin, so no separate login needed as long as the admin is already
|
||||||
|
logged into Jellyfin in that browser) and uses it to poll the real,
|
||||||
|
auth-gated `/SyncPlus/Stats` endpoint every second. The plugin's actual
|
||||||
|
Dashboard config page is now just a static link to this standalone page --
|
||||||
|
static HTML links work fine even with scripts stripped.
|
||||||
|
- **Live-verified 2026-07-09**: confirmed the DOMPurify script-stripping
|
||||||
|
diagnosis directly (0 script tags in the post-injection DOM), confirmed
|
||||||
|
the standalone page loads and polls correctly (timestamp advancing
|
||||||
|
second-to-second, no console errors), and confirmed a real SyncPlay group
|
||||||
|
join shows up correctly in the JSON response (device/user name populated
|
||||||
|
immediately; position/bitrate fields are null until actual playback
|
||||||
|
starts, which is correct -- there's nothing to report yet).
|
||||||
|
- Not yet tested: real multi-device drift numbers (only one browser session
|
||||||
|
was available this round) and the config-page link's `target="_blank"`
|
||||||
|
actually opening a new tab in a real browser (vs. this session's
|
||||||
|
single-tab preview environment, where it was verified by direct
|
||||||
|
navigation instead).
|
||||||
|
- **Real bug found and fixed same day**: the stats page's JS assumed
|
||||||
|
camelCase JSON property names (`group.members`, `m.deviceId`), but
|
||||||
|
Jellyfin's API serializes in PascalCase (`Members`, `DeviceId`) --
|
||||||
|
confirmed against the actual response body, which had been sitting right
|
||||||
|
there in an earlier curl test output the whole time. Threw `Cannot read
|
||||||
|
properties of undefined (reading 'forEach')` as soon as a real group
|
||||||
|
existed to render. Fixed by matching the JS property access to the DTOs
|
||||||
|
exactly; worth double-checking actual response shapes against assumptions
|
||||||
|
before shipping JS that consumes them, not just assuming a convention.
|
||||||
|
- **Manual "sync me" button added 2026-07-09.** User-requested: a one-shot,
|
||||||
|
human-triggered action to fix your own drift on demand, as an alternative
|
||||||
|
to the automatic corrector that got reverted for looping. `POST
|
||||||
|
/SyncPlus/Stats/Sync` -- any authenticated user (not admin-gated like
|
||||||
|
`GetStats`, since it only ever touches the caller's own session) seeks
|
||||||
|
their own device to match whoever else in their group is furthest behind,
|
||||||
|
using the exact same single-session `SendSyncPlayCommand` seek mechanism
|
||||||
|
the reverted auto-corrector used. The difference that matters: this only
|
||||||
|
ever fires once, when a human clicks a button -- there's no "again" until
|
||||||
|
someone clicks again, so it structurally can't produce the
|
||||||
|
re-correct-faster-than-the-previous-seek-lands feedback loop that broke
|
||||||
|
the automatic version. No-ops safely (with an explanatory message, not an
|
||||||
|
error) if the caller isn't in a group, isn't playing anything, or is
|
||||||
|
already at or behind the group's laggard -- never pulls
|
||||||
|
anyone but the caller, and never moves the caller forward past the
|
||||||
|
laggard's position (same spoiler-avoidance principle as before). Button
|
||||||
|
added to the stats page (`/SyncPlus/Stats/Page`).
|
||||||
|
Live-verified 2026-07-09: confirmed both no-op paths fire correctly and
|
||||||
|
with the right message ("not in a group", "nothing playing on this
|
||||||
|
device"). Not yet tested: the actual successful-correction path with a
|
||||||
|
real drifted second device (only one browser session available this
|
||||||
|
round, same recurring constraint as everything else that needs two real
|
||||||
|
logins).
|
||||||
|
|
||||||
## Open questions (revisit before/at the relevant phase, not now)
|
## Open questions (revisit before/at the relevant phase, not now)
|
||||||
|
|
||||||
- Exact client compatibility target for v1 -- web client only, or also
|
- Exact client compatibility target for v1 -- web client only, or also
|
||||||
|
|||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
name: "JellyfinSyncPlus"
|
||||||
|
guid: "267dbfe9-bb9c-4eeb-97aa-f0449283cfe6"
|
||||||
|
version: "0.2.0.0"
|
||||||
|
targetAbi: "10.11.6.0"
|
||||||
|
framework: "net9.0"
|
||||||
|
owner: "cynic"
|
||||||
|
overview: "SyncPlay stats for nerds: live drift/playback view plus a manual one-shot sync button."
|
||||||
|
description: >
|
||||||
|
Read-only live view of every active SyncPlay group's members -- drift,
|
||||||
|
position, play method, bitrate, and codec info -- plus a manual "sync me
|
||||||
|
to group" button for one-shot self-correction. Does not touch playback
|
||||||
|
automatically: earlier attempts at automatic quality-forcing and
|
||||||
|
automatic drift correction both caused real problems in live use and were
|
||||||
|
reverted (see PLAN.md). Reachable via Dashboard -> Plugins -> JellyfinSyncPlus.
|
||||||
|
category: "General"
|
||||||
|
artifacts:
|
||||||
|
- "JellyfinSyncPlus.dll"
|
||||||
|
changelog: >
|
||||||
|
0.2.0.0: SyncPlay stats-for-nerds page and manual one-shot "sync me to
|
||||||
|
group" button. No automatic playback intervention.
|
||||||
|
0.1.0.0: Phase 0 scaffold. Trivial plugin, no functionality yet.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
services:
|
||||||
|
jellyfin-dev:
|
||||||
|
image: linuxserver/jellyfin:10.11.6
|
||||||
|
container_name: jellyfinsyncplus-dev
|
||||||
|
environment:
|
||||||
|
- PUID=1000
|
||||||
|
- PGID=1000
|
||||||
|
- TZ=Etc/UTC
|
||||||
|
ports:
|
||||||
|
- "8096:8096"
|
||||||
|
volumes:
|
||||||
|
- ./config:/config
|
||||||
|
- ./plugins/JellyfinSyncPlus_0.1.0.0:/config/data/plugins/JellyfinSyncPlus_0.1.0.0
|
||||||
|
# Real video content for testing Phase 2 relay. Read-only: recordings here are
|
||||||
|
# never to be deleted or modified by this project, mounting :ro enforces that
|
||||||
|
# rather than just relying on discipline.
|
||||||
|
- /mnt/geforce:/media/geforce:ro
|
||||||
|
restart: "no"
|
||||||
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Extracts the private (non-NuGet) Jellyfin server assemblies our plugin needs
|
||||||
|
# a compile-time reference to -- see PLAN.md "Known friction / risks" for why.
|
||||||
|
# Re-run this after the pinned server image (dev/docker-compose.yml) changes.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CONTAINER=jellyfinsyncplus-dev
|
||||||
|
JELLYFIN_VERSION=10.11.6
|
||||||
|
DEST="$(dirname "$0")/../lib/jellyfin-${JELLYFIN_VERSION}"
|
||||||
|
|
||||||
|
mkdir -p "$DEST"
|
||||||
|
docker cp "${CONTAINER}:/usr/lib/jellyfin/bin/Emby.Server.Implementations.dll" "$DEST/"
|
||||||
|
docker cp "${CONTAINER}:/usr/lib/jellyfin/bin/Jellyfin.Api.dll" "$DEST/"
|
||||||
|
|
||||||
|
echo "Extracted to $DEST"
|
||||||
Executable
+48
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Builds, packages, and updates manifest.json for a tagged release. Used by
|
||||||
|
# .github/workflows/release.yml; safe to run locally too for a dry run.
|
||||||
|
#
|
||||||
|
# Required env vars:
|
||||||
|
# VERSION -- e.g. 0.2.0.0 (must match build.yaml's version)
|
||||||
|
# SERVER_URL -- e.g. https://gitea.mrcynic.site
|
||||||
|
# REPO_OWNER -- Gitea org/user the repo lives under
|
||||||
|
# REPO_NAME -- Gitea repo name
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
: "${VERSION:?VERSION env var required, e.g. 0.2.0.0}"
|
||||||
|
: "${SERVER_URL:?SERVER_URL env var required, e.g. https://gitea.mrcynic.site}"
|
||||||
|
: "${REPO_OWNER:?REPO_OWNER env var required}"
|
||||||
|
: "${REPO_NAME:?REPO_NAME env var required}"
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
echo "Building Release configuration..."
|
||||||
|
dotnet build src/JellyfinSyncPlus/JellyfinSyncPlus.csproj -c Release
|
||||||
|
|
||||||
|
BUILD_VERSION=$(grep -oP '(?<=^version: ")[^"]*' build.yaml)
|
||||||
|
if [ "$BUILD_VERSION" != "$VERSION" ]; then
|
||||||
|
echo "ERROR: build.yaml version ($BUILD_VERSION) does not match requested VERSION ($VERSION)." >&2
|
||||||
|
echo "Update build.yaml's version field to match the git tag before releasing." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p release
|
||||||
|
ZIP_NAME="JellyfinSyncPlus_${VERSION}.zip"
|
||||||
|
ZIP_PATH="release/${ZIP_NAME}"
|
||||||
|
rm -f "$ZIP_PATH"
|
||||||
|
(cd src/JellyfinSyncPlus/bin/Release/net9.0 && zip -j "$ROOT/$ZIP_PATH" JellyfinSyncPlus.dll)
|
||||||
|
|
||||||
|
CHECKSUM=$(md5sum "$ZIP_PATH" | awk '{print $1}')
|
||||||
|
SOURCE_URL="${SERVER_URL}/${REPO_OWNER}/${REPO_NAME}/releases/download/v${VERSION}/${ZIP_NAME}"
|
||||||
|
|
||||||
|
python3 "$ROOT/dev/update_manifest.py" \
|
||||||
|
--build-yaml "$ROOT/build.yaml" \
|
||||||
|
--manifest "$ROOT/manifest.json" \
|
||||||
|
--version "$VERSION" \
|
||||||
|
--checksum "$CHECKSUM" \
|
||||||
|
--source-url "$SOURCE_URL"
|
||||||
|
|
||||||
|
echo "Packaged $ZIP_PATH"
|
||||||
|
echo "Checksum: $CHECKSUM"
|
||||||
|
echo "Source URL: $SOURCE_URL"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"category": "General",
|
||||||
|
"name": "JellyfinSyncPlus",
|
||||||
|
"description": "Fixes SyncPlay drift via group quality negotiation, host-relay streaming, and active drift correction.",
|
||||||
|
"overview": "Fixes SyncPlay drift via group quality negotiation, host-relay streaming, and active drift correction.",
|
||||||
|
"owner": "cynic",
|
||||||
|
"version": "0.1.0.0",
|
||||||
|
"status": "Active",
|
||||||
|
"autoUpdate": false,
|
||||||
|
"targetAbi": "10.11.6.0",
|
||||||
|
"timestamp": "2026-07-09T00:00:00.0000000Z",
|
||||||
|
"guid": "267dbfe9-bb9c-4eeb-97aa-f0449283cfe6"
|
||||||
|
}
|
||||||
Executable
+85
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Appends (or replaces) this build's version entry in manifest.json, using build.yaml
|
||||||
|
for the plugin-level metadata (guid, name, description, etc.) and changelog. Run by
|
||||||
|
dev/package-release.sh; not meant to be invoked directly outside that flow."""
|
||||||
|
import argparse
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def parse_build_yaml(path):
|
||||||
|
# build.yaml is simple enough to not need a real YAML parser dependency.
|
||||||
|
text = open(path, encoding="utf-8").read()
|
||||||
|
fields = {}
|
||||||
|
for key in ("name", "guid", "targetAbi", "framework", "owner", "overview", "category"):
|
||||||
|
m = re.search(rf'^{key}:\s*"([^"]*)"', text, re.MULTILINE)
|
||||||
|
if m:
|
||||||
|
fields[key] = m.group(1)
|
||||||
|
|
||||||
|
def block(key):
|
||||||
|
m = re.search(rf'^{key}:\s*>\s*\n((?: .*\n?)+)', text, re.MULTILINE)
|
||||||
|
if not m:
|
||||||
|
return ""
|
||||||
|
lines = [line[2:].rstrip() for line in m.group(1).splitlines()]
|
||||||
|
return " ".join(line for line in lines if line).strip()
|
||||||
|
|
||||||
|
fields["description"] = block("description")
|
||||||
|
fields["changelog"] = block("changelog")
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--build-yaml", required=True)
|
||||||
|
parser.add_argument("--manifest", required=True)
|
||||||
|
parser.add_argument("--version", required=True)
|
||||||
|
parser.add_argument("--checksum", required=True)
|
||||||
|
parser.add_argument("--source-url", required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
build = parse_build_yaml(args.build_yaml)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(args.manifest, encoding="utf-8") as f:
|
||||||
|
manifest = json.load(f)
|
||||||
|
except FileNotFoundError:
|
||||||
|
manifest = []
|
||||||
|
|
||||||
|
entry = next((p for p in manifest if p.get("guid") == build["guid"]), None)
|
||||||
|
if entry is None:
|
||||||
|
entry = {
|
||||||
|
"guid": build["guid"],
|
||||||
|
"name": build["name"],
|
||||||
|
"description": build["description"],
|
||||||
|
"overview": build["overview"],
|
||||||
|
"owner": build["owner"],
|
||||||
|
"category": build["category"],
|
||||||
|
"versions": [],
|
||||||
|
}
|
||||||
|
manifest.append(entry)
|
||||||
|
else:
|
||||||
|
entry["description"] = build["description"]
|
||||||
|
entry["overview"] = build["overview"]
|
||||||
|
entry["owner"] = build["owner"]
|
||||||
|
entry["category"] = build["category"]
|
||||||
|
|
||||||
|
entry["versions"] = [v for v in entry["versions"] if v["version"] != args.version]
|
||||||
|
entry["versions"].insert(0, {
|
||||||
|
"version": args.version,
|
||||||
|
"changelog": build["changelog"],
|
||||||
|
"targetAbi": build["targetAbi"],
|
||||||
|
"sourceUrl": args.source_url,
|
||||||
|
"checksum": args.checksum,
|
||||||
|
"timestamp": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
})
|
||||||
|
|
||||||
|
with open(args.manifest, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(manifest, f, indent=4)
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
print(f"Updated {args.manifest} with version {args.version}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"guid": "267dbfe9-bb9c-4eeb-97aa-f0449283cfe6",
|
||||||
|
"name": "JellyfinSyncPlus",
|
||||||
|
"description": "Read-only live view of every active SyncPlay group's members -- drift, position, play method, bitrate, and codec info -- plus a manual \"sync me to group\" button for one-shot self-correction. Does not touch playback automatically: earlier attempts at automatic quality-forcing and automatic drift correction both caused real problems in live use and were reverted (see PLAN.md). Reachable via Dashboard -> Plugins -> JellyfinSyncPlus.",
|
||||||
|
"overview": "SyncPlay stats for nerds: live drift/playback view plus a manual one-shot sync button.",
|
||||||
|
"owner": "cynic",
|
||||||
|
"category": "General",
|
||||||
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "0.2.0.0",
|
||||||
|
"changelog": "0.2.0.0: SyncPlay stats-for-nerds page and manual one-shot \"sync me to group\" button. No automatic playback intervention. 0.1.0.0: Phase 0 scaffold. Trivial plugin, no functionality yet.",
|
||||||
|
"targetAbi": "10.11.6.0",
|
||||||
|
"sourceUrl": "https://gitea.mrcynic.site/seer/JellyfinSyncPlus/releases/download/v0.2.0.0/JellyfinSyncPlus_0.2.0.0.zip",
|
||||||
|
"checksum": "4ff9b85a384bba6c246a46605e2be2e1",
|
||||||
|
"timestamp": "2026-07-09T15:05:04Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using MediaBrowser.Model.Plugins;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SyncPlus.Configuration;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public class PluginConfiguration : BasePluginConfiguration
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>JellyfinSyncPlus</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="SyncPlusConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button">
|
||||||
|
<div data-role="content">
|
||||||
|
<div class="content-primary">
|
||||||
|
<p>JellyfinSyncPlus is loaded. No active playback features right now -- see PLAN.md for why (quality negotiation, host-relay, and active drift correction were all tried and reverted after real bugs in live use).</p>
|
||||||
|
<p>
|
||||||
|
<a is="emby-linkbutton" class="raised button-submit block emby-button" href="/SyncPlus/Stats/Page" target="_blank" rel="noopener">Open SyncPlay stats for nerds</a>
|
||||||
|
</p>
|
||||||
|
<p>Opens in a new tab (plugin config pages can't run their own JavaScript -- Jellyfin's web client strips <script> tags from injected plugin pages -- so the live stats page is served standalone instead).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<RootNamespace>Jellyfin.Plugin.SyncPlus</RootNamespace>
|
||||||
|
<AssemblyName>JellyfinSyncPlus</AssemblyName>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Pinned to the exact server build this plugin targets (linuxserver/jellyfin:10.11.6
|
||||||
|
in the entertainment k8s namespace); see PLAN.md "Known friction / risks". -->
|
||||||
|
<PackageReference Include="Jellyfin.Controller" Version="10.11.6">
|
||||||
|
<ExcludeAssets>runtime</ExcludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Jellyfin.Model" Version="10.11.6">
|
||||||
|
<ExcludeAssets>runtime</ExcludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- MVC controllers need the ASP.NET Core shared framework Jellyfin itself runs on.
|
||||||
|
Not bundled into our output; it's already present in the host process. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Emby.Server.Implementations.dll isn't published as a NuGet package (it's Jellyfin's
|
||||||
|
private server-implementation assembly, not part of the plugin SDK surface). Extracted
|
||||||
|
from the exact pinned 10.11.6 image (see dev/docker-compose.yml) for compile-time
|
||||||
|
reference only, to decorate the concrete SyncPlayManager; Private=false means we don't
|
||||||
|
copy it into our output, since it's already loaded in the host Jellyfin process at
|
||||||
|
runtime. If the pinned server version ever changes, re-extract from the new image's
|
||||||
|
/usr/lib/jellyfin/bin/Emby.Server.Implementations.dll (dev/extract-private-refs.sh
|
||||||
|
automates this). -->
|
||||||
|
<ItemGroup>
|
||||||
|
<Reference Include="Emby.Server.Implementations">
|
||||||
|
<HintPath>..\..\lib\jellyfin-10.11.6\Emby.Server.Implementations.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Remove="Configuration\configPage.html" />
|
||||||
|
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using Jellyfin.Plugin.SyncPlus.Configuration;
|
||||||
|
using MediaBrowser.Common.Configuration;
|
||||||
|
using MediaBrowser.Common.Plugins;
|
||||||
|
using MediaBrowser.Model.Plugins;
|
||||||
|
using MediaBrowser.Model.Serialization;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SyncPlus;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The JellyfinSyncPlus plugin entry point.
|
||||||
|
/// </summary>
|
||||||
|
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="Plugin"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
|
||||||
|
/// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
|
||||||
|
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
|
||||||
|
: base(applicationPaths, xmlSerializer)
|
||||||
|
{
|
||||||
|
Instance = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string Name => "JellyfinSyncPlus";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override Guid Id => Guid.Parse("267dbfe9-bb9c-4eeb-97aa-f0449283cfe6");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string Description => "Group quality negotiation, host-relay streaming, and active drift correction for SyncPlay.";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the current plugin instance.
|
||||||
|
/// </summary>
|
||||||
|
public static Plugin? Instance { get; private set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IEnumerable<PluginPageInfo> GetPages()
|
||||||
|
{
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new PluginPageInfo
|
||||||
|
{
|
||||||
|
Name = Name,
|
||||||
|
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using Jellyfin.Plugin.SyncPlus.SyncPlay;
|
||||||
|
using MediaBrowser.Controller;
|
||||||
|
using MediaBrowser.Controller.Plugins;
|
||||||
|
using MediaBrowser.Controller.SyncPlay;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using ConcreteSyncPlayManager = Emby.Server.Implementations.SyncPlay.SyncPlayManager;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SyncPlus;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public class PluginServiceRegistrator : IPluginServiceRegistrator
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
|
||||||
|
{
|
||||||
|
serviceCollection.AddSingleton<IGroupMembershipStore, GroupMembershipStore>();
|
||||||
|
|
||||||
|
// Self-bind the concrete, built-in SyncPlayManager so our decorator can wrap it,
|
||||||
|
// then replace the ISyncPlayManager registration with the decorator. Plugin
|
||||||
|
// RegisterServices runs after core's, so this registration wins for resolution.
|
||||||
|
serviceCollection.AddSingleton<ConcreteSyncPlayManager>();
|
||||||
|
serviceCollection.AddSingleton<ISyncPlayManager, SyncPlusSyncPlayManager>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SyncPlus.SyncPlay;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public class GroupMembershipStore : IGroupMembershipStore
|
||||||
|
{
|
||||||
|
private readonly ILogger<GroupMembershipStore> _logger;
|
||||||
|
private readonly ConcurrentDictionary<Guid, HashSet<string>> _groupDevices = new();
|
||||||
|
private readonly ConcurrentDictionary<string, Guid> _deviceGroup = new();
|
||||||
|
private readonly object _lock = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="GroupMembershipStore"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">Instance of the <see cref="ILogger{GroupMembershipStore}"/> interface.</param>
|
||||||
|
public GroupMembershipStore(ILogger<GroupMembershipStore> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void SessionJoined(Guid groupId, string deviceId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(deviceId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
var devices = _groupDevices.GetOrAdd(groupId, static _ => new HashSet<string>());
|
||||||
|
devices.Add(deviceId);
|
||||||
|
_deviceGroup[deviceId] = groupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("SyncPlus: device {DeviceId} joined SyncPlay group {GroupId}", deviceId, groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void SessionLeft(Guid groupId, string deviceId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(deviceId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (_groupDevices.TryGetValue(groupId, out var devices))
|
||||||
|
{
|
||||||
|
devices.Remove(deviceId);
|
||||||
|
if (devices.Count == 0)
|
||||||
|
{
|
||||||
|
_groupDevices.TryRemove(groupId, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_deviceGroup.TryRemove(deviceId, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("SyncPlus: device {DeviceId} left SyncPlay group {GroupId}", deviceId, groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Guid? GetGroupForDevice(string deviceId)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(deviceId) && _deviceGroup.TryGetValue(deviceId, out var groupId))
|
||||||
|
{
|
||||||
|
return groupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyDictionary<Guid, IReadOnlyCollection<string>> GetAllGroups()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _groupDevices.ToDictionary(
|
||||||
|
kvp => kvp.Key,
|
||||||
|
kvp => (IReadOnlyCollection<string>)kvp.Value.ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SyncPlus.SyncPlay;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tracks which device is in which SyncPlay group. Purely observational -- used by
|
||||||
|
/// <see cref="SyncPlusStatsController"/> to know which live sessions to group together
|
||||||
|
/// when reporting stats. Does not mutate any request or issue any commands; this is the
|
||||||
|
/// same passive tracking that was never the source of the bugs in the (reverted) active
|
||||||
|
/// quality-forcing and drift-correction attempts -- see PLAN.md "Rough phase breakdown"
|
||||||
|
/// for that history.
|
||||||
|
/// </summary>
|
||||||
|
public interface IGroupMembershipStore
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Records that a device joined a group, replacing any prior group membership for it.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="groupId">The SyncPlay group id.</param>
|
||||||
|
/// <param name="deviceId">The device id of the joining session.</param>
|
||||||
|
void SessionJoined(Guid groupId, string deviceId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records that a device left a group.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="groupId">The SyncPlay group id.</param>
|
||||||
|
/// <param name="deviceId">The device id of the leaving session.</param>
|
||||||
|
void SessionLeft(Guid groupId, string deviceId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the group a device currently belongs to, if any.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="deviceId">The device id.</param>
|
||||||
|
/// <returns>The group id, or null if the device isn't in a tracked group.</returns>
|
||||||
|
Guid? GetGroupForDevice(string deviceId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a snapshot of all currently tracked groups and their member device ids.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A read-only view of group id to member device ids.</returns>
|
||||||
|
System.Collections.Generic.IReadOnlyDictionary<Guid, System.Collections.Generic.IReadOnlyCollection<string>> GetAllGroups();
|
||||||
|
}
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediaBrowser.Common.Api;
|
||||||
|
using MediaBrowser.Controller.Session;
|
||||||
|
using MediaBrowser.Model.Session;
|
||||||
|
using MediaBrowser.Model.SyncPlay;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SyncPlus.SyncPlay;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "Stats for nerds" for SyncPlay, plus a manual "sync me" action. The stats view
|
||||||
|
/// (<see cref="GetStats"/>) is read-only: it reads live from
|
||||||
|
/// <see cref="ISessionManager.Sessions"/> on each request, no in-memory state, no
|
||||||
|
/// automatic action. <see cref="SyncMe"/> *does* act, but only once, only on an explicit
|
||||||
|
/// user click -- that distinction matters: the earlier *automatic* drift corrector (see
|
||||||
|
/// PLAN.md) used this exact same single-session-seek mechanism but running continuously
|
||||||
|
/// in the background, which caused a real feedback loop (re-correcting faster than a
|
||||||
|
/// previous seek could land). A one-shot, human-triggered action can't loop -- there's no
|
||||||
|
/// "again" until someone clicks again.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("SyncPlus/Stats")]
|
||||||
|
public class SyncPlusStatsController : ControllerBase
|
||||||
|
{
|
||||||
|
private const string DeviceIdClaimType = "Jellyfin-DeviceId";
|
||||||
|
|
||||||
|
private readonly ISessionManager _sessionManager;
|
||||||
|
private readonly IGroupMembershipStore _membershipStore;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="SyncPlusStatsController"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sessionManager">Used to read live session/playback state.</param>
|
||||||
|
/// <param name="membershipStore">Tracks which device is in which SyncPlay group.</param>
|
||||||
|
public SyncPlusStatsController(ISessionManager sessionManager, IGroupMembershipStore membershipStore)
|
||||||
|
{
|
||||||
|
_sessionManager = sessionManager;
|
||||||
|
_membershipStore = membershipStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serves the standalone "stats for nerds" HTML page. This is a real page navigation
|
||||||
|
/// (not content injected into the SPA), which matters: Jellyfin's SPA runs plugin
|
||||||
|
/// config page HTML through DOMPurify before injecting it, which strips
|
||||||
|
/// <c><script></c> tags entirely -- confirmed live, the config page's own inline
|
||||||
|
/// script never ran at all (0 script tags survived in the DOM). A genuine page load
|
||||||
|
/// isn't sanitized that way, so script works here. No auth required to load the page
|
||||||
|
/// shell itself (nothing sensitive in it); the page's own JS reads the current user's
|
||||||
|
/// token straight out of the same <c>jellyfin_credentials</c> localStorage entry the
|
||||||
|
/// main web client uses (same origin, so no login prompt needed as long as the admin
|
||||||
|
/// is already logged into Jellyfin in that browser) and uses it to call the real,
|
||||||
|
/// auth-gated <see cref="GetStats"/> endpoint below.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A standalone HTML page.</returns>
|
||||||
|
[HttpGet("Page")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[Produces("text/html")]
|
||||||
|
public ContentResult GetStatsPage()
|
||||||
|
{
|
||||||
|
return Content(StatsPageHtml, "text/html");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a live snapshot of every active SyncPlay group's members and their drift
|
||||||
|
/// relative to whoever in the group is furthest behind.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>One entry per active SyncPlay group.</returns>
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
|
public ActionResult<IReadOnlyList<GroupStatsDto>> GetStats()
|
||||||
|
{
|
||||||
|
var sessionsByDevice = _sessionManager.Sessions
|
||||||
|
.Where(s => !string.IsNullOrEmpty(s.DeviceId))
|
||||||
|
.GroupBy(s => s.DeviceId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.First());
|
||||||
|
|
||||||
|
var result = new List<GroupStatsDto>();
|
||||||
|
foreach (var (groupId, deviceIds) in _membershipStore.GetAllGroups())
|
||||||
|
{
|
||||||
|
var members = new List<MemberStatsDto>();
|
||||||
|
long? laggardTicks = null;
|
||||||
|
|
||||||
|
foreach (var deviceId in deviceIds)
|
||||||
|
{
|
||||||
|
if (!sessionsByDevice.TryGetValue(deviceId, out var session))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var positionTicks = session.PlayState?.PositionTicks;
|
||||||
|
if (positionTicks is not null && !(session.PlayState?.IsPaused ?? false))
|
||||||
|
{
|
||||||
|
laggardTicks = laggardTicks is null ? positionTicks : Math.Min(laggardTicks.Value, positionTicks.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var deviceId in deviceIds)
|
||||||
|
{
|
||||||
|
if (!sessionsByDevice.TryGetValue(deviceId, out var session))
|
||||||
|
{
|
||||||
|
members.Add(new MemberStatsDto { DeviceId = deviceId, Connected = false });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var positionTicks = session.PlayState?.PositionTicks;
|
||||||
|
var transcoding = session.TranscodingInfo;
|
||||||
|
|
||||||
|
members.Add(new MemberStatsDto
|
||||||
|
{
|
||||||
|
DeviceId = deviceId,
|
||||||
|
Connected = true,
|
||||||
|
DeviceName = session.DeviceName,
|
||||||
|
UserName = session.UserName,
|
||||||
|
NowPlaying = session.NowPlayingItem?.Name,
|
||||||
|
PositionTicks = positionTicks,
|
||||||
|
PositionFormatted = positionTicks is null ? null : TimeSpan.FromTicks(positionTicks.Value).ToString(@"hh\:mm\:ss"),
|
||||||
|
DriftMs = positionTicks is null || laggardTicks is null
|
||||||
|
? null
|
||||||
|
: (positionTicks.Value - laggardTicks.Value) / TimeSpan.TicksPerMillisecond,
|
||||||
|
IsPaused = session.PlayState?.IsPaused,
|
||||||
|
PlayMethod = session.PlayState?.PlayMethod?.ToString(),
|
||||||
|
Bitrate = transcoding?.Bitrate,
|
||||||
|
VideoCodec = transcoding?.VideoCodec,
|
||||||
|
AudioCodec = transcoding?.AudioCodec,
|
||||||
|
Container = transcoding?.Container,
|
||||||
|
IsVideoDirect = transcoding is null ? null : transcoding.IsVideoDirect,
|
||||||
|
IsAudioDirect = transcoding is null ? null : transcoding.IsAudioDirect,
|
||||||
|
TranscodeReasons = transcoding is null ? null : transcoding.TranscodeReasons.ToString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Add(new GroupStatsDto
|
||||||
|
{
|
||||||
|
GroupId = groupId,
|
||||||
|
Members = members
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One-shot: seeks the calling device's own session to match whoever in its SyncPlay
|
||||||
|
/// group is furthest behind (never the other direction -- if the caller is themselves
|
||||||
|
/// the laggard, there's nothing more-behind to sync to, and the request no-ops with an
|
||||||
|
/// explanatory result rather than pulling someone else forward). Doesn't touch any
|
||||||
|
/// other session. Any authenticated user can sync themselves; this isn't an
|
||||||
|
/// admin-only action like <see cref="GetStats"/>, since it only ever affects the
|
||||||
|
/// caller's own playback.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>What happened.</returns>
|
||||||
|
[HttpPost("Sync")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<SyncResultDto>> SyncMe(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var deviceId = User.Claims.FirstOrDefault(c => c.Type == DeviceIdClaimType)?.Value;
|
||||||
|
if (string.IsNullOrEmpty(deviceId))
|
||||||
|
{
|
||||||
|
return BadRequest(new SyncResultDto { Message = "Could not determine your device id." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var groupId = _membershipStore.GetGroupForDevice(deviceId);
|
||||||
|
if (groupId is null)
|
||||||
|
{
|
||||||
|
return BadRequest(new SyncResultDto { Message = "You're not in a SyncPlay group right now." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var mySession = _sessionManager.Sessions.FirstOrDefault(s => s.DeviceId == deviceId);
|
||||||
|
var myPositionTicks = mySession?.PlayState?.PositionTicks;
|
||||||
|
if (mySession is null || myPositionTicks is null)
|
||||||
|
{
|
||||||
|
return BadRequest(new SyncResultDto { Message = "Nothing is playing on this device right now." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Guid.TryParse(mySession.PlaylistItemId, out var playlistItemId))
|
||||||
|
{
|
||||||
|
return BadRequest(new SyncResultDto { Message = "Nothing is playing on this device right now." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var otherDeviceIds = _membershipStore.GetAllGroups().TryGetValue(groupId.Value, out var members)
|
||||||
|
? members.Where(d => d != deviceId)
|
||||||
|
: Enumerable.Empty<string>();
|
||||||
|
|
||||||
|
long? laggardTicks = null;
|
||||||
|
foreach (var otherDeviceId in otherDeviceIds)
|
||||||
|
{
|
||||||
|
var otherSession = _sessionManager.Sessions.FirstOrDefault(s => s.DeviceId == otherDeviceId);
|
||||||
|
var otherPositionTicks = otherSession?.PlayState?.PositionTicks;
|
||||||
|
if (otherPositionTicks is null || (otherSession?.PlayState?.IsPaused ?? false))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
laggardTicks = laggardTicks is null ? otherPositionTicks : Math.Min(laggardTicks.Value, otherPositionTicks.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (laggardTicks is null)
|
||||||
|
{
|
||||||
|
return BadRequest(new SyncResultDto { Message = "No one else in the group has a reportable position right now." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var driftMs = (myPositionTicks.Value - laggardTicks.Value) / TimeSpan.TicksPerMillisecond;
|
||||||
|
if (driftMs <= 0)
|
||||||
|
{
|
||||||
|
return Ok(new SyncResultDto { Message = "You're already at or behind the group's furthest-behind member -- nothing to do.", MovedMs = 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var seekCommand = new SendCommand(
|
||||||
|
groupId: groupId.Value,
|
||||||
|
playlistItemId: playlistItemId,
|
||||||
|
when: now,
|
||||||
|
command: SendCommandType.Seek,
|
||||||
|
positionTicks: laggardTicks.Value,
|
||||||
|
emittedAt: now);
|
||||||
|
|
||||||
|
await _sessionManager.SendSyncPlayCommand(mySession.Id, seekCommand, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var message = new MessageCommand
|
||||||
|
{
|
||||||
|
Header = "SyncPlay",
|
||||||
|
Text = "Synced you to the group.",
|
||||||
|
TimeoutMs = 3000
|
||||||
|
};
|
||||||
|
await _sessionManager.SendMessageCommand(mySession.Id, mySession.Id, message, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return Ok(new SyncResultDto { Message = $"Synced -- moved back {driftMs} ms.", MovedMs = driftMs });
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string StatsPageHtml = """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>SyncPlus -- stats for nerds</title>
|
||||||
|
<style>
|
||||||
|
body { background:#101010; color:#ddd; font-family:sans-serif; padding:1.5em; }
|
||||||
|
h1 { font-size:1.3em; }
|
||||||
|
table { width:100%; border-collapse:collapse; margin-top:1em; }
|
||||||
|
th, td { text-align:left; padding:4px 8px; border-bottom:1px solid #333; }
|
||||||
|
th { color:#888; font-weight:normal; }
|
||||||
|
.drift-ok { color:#6bd68b; }
|
||||||
|
.drift-warn { color:#ffb86b; }
|
||||||
|
.drift-bad { color:#ff6b6b; font-weight:bold; }
|
||||||
|
#status { color:#888; font-size:0.85em; }
|
||||||
|
button { background:#00a4dc; color:#fff; border:none; border-radius:3px; padding:0.6em 1.2em; font-size:0.95em; cursor:pointer; }
|
||||||
|
button:disabled { background:#444; cursor:default; }
|
||||||
|
#syncResult { margin-left:1em; color:#888; font-size:0.9em; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>SyncPlus -- stats for nerds</h1>
|
||||||
|
<p>
|
||||||
|
<button id="syncBtn">Sync me to group</button>
|
||||||
|
<span id="syncResult"></span>
|
||||||
|
</p>
|
||||||
|
<p>If you notice you're ahead of or behind everyone else, click this on the device you want moved -- it seeks just that one device to match whoever's furthest behind, without touching anyone else's playback.</p>
|
||||||
|
<p id="status">Loading...</p>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var root = document.getElementById('root');
|
||||||
|
var status = document.getElementById('status');
|
||||||
|
var syncBtn = document.getElementById('syncBtn');
|
||||||
|
var syncResult = document.getElementById('syncResult');
|
||||||
|
|
||||||
|
function getToken() {
|
||||||
|
try {
|
||||||
|
var raw = localStorage.getItem('jellyfin_credentials');
|
||||||
|
if (!raw) return null;
|
||||||
|
var parsed = JSON.parse(raw);
|
||||||
|
var server = parsed.Servers && parsed.Servers[0];
|
||||||
|
return server ? server.AccessToken : null;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtBitrate(bps) {
|
||||||
|
if (bps === null || bps === undefined) return '-';
|
||||||
|
return (bps / 1000000).toFixed(1) + ' Mbps';
|
||||||
|
}
|
||||||
|
|
||||||
|
function driftClass(driftMs) {
|
||||||
|
if (driftMs === null || driftMs === undefined) return '';
|
||||||
|
if (driftMs >= 3000) return 'drift-bad';
|
||||||
|
if (driftMs >= 1000) return 'drift-warn';
|
||||||
|
return 'drift-ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jellyfin's API serializes JSON in PascalCase (matching the C# property names
|
||||||
|
// directly, e.g. "GroupId"/"Members"), not the camelCase a lot of other JSON APIs
|
||||||
|
// use -- confirmed against the real response body, not assumed. Keep these matching
|
||||||
|
// SyncPlusStatsController's GroupStatsDto/MemberStatsDto exactly.
|
||||||
|
function renderGroup(group) {
|
||||||
|
var html = '<h3 style="margin-top:2em;">Group ' + group.GroupId + '</h3>';
|
||||||
|
html += '<table><thead><tr>' +
|
||||||
|
'<th>User</th><th>Device</th><th>Now playing</th><th>Position</th><th>Drift</th>' +
|
||||||
|
'<th>Method</th><th>Bitrate</th><th>Video</th><th>Audio</th><th>Transcode reason</th>' +
|
||||||
|
'</tr></thead><tbody>';
|
||||||
|
|
||||||
|
(group.Members || []).forEach(function (m) {
|
||||||
|
if (!m.Connected) {
|
||||||
|
html += '<tr><td colspan="10"><em>' + m.DeviceId + ' (disconnected)</em></td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var driftText = m.DriftMs === null || m.DriftMs === undefined ? '-' : m.DriftMs + ' ms';
|
||||||
|
var pausedText = m.IsPaused ? ' (paused)' : '';
|
||||||
|
html += '<tr>' +
|
||||||
|
'<td>' + (m.UserName || '-') + '</td>' +
|
||||||
|
'<td>' + (m.DeviceName || '-') + '</td>' +
|
||||||
|
'<td>' + (m.NowPlaying || '-') + '</td>' +
|
||||||
|
'<td>' + (m.PositionFormatted || '-') + pausedText + '</td>' +
|
||||||
|
'<td class="' + driftClass(m.DriftMs) + '">' + driftText + '</td>' +
|
||||||
|
'<td>' + (m.PlayMethod || '-') + '</td>' +
|
||||||
|
'<td>' + fmtBitrate(m.Bitrate) + '</td>' +
|
||||||
|
'<td>' + (m.VideoCodec || '-') + (m.IsVideoDirect === false ? ' (transcoded)' : m.IsVideoDirect === true ? ' (copy)' : '') + '</td>' +
|
||||||
|
'<td>' + (m.AudioCodec || '-') + (m.IsAudioDirect === false ? ' (transcoded)' : m.IsAudioDirect === true ? ' (copy)' : '') + '</td>' +
|
||||||
|
'<td>' + (m.TranscodeReasons || '-') + '</td>' +
|
||||||
|
'</tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
var token = getToken();
|
||||||
|
if (!token) {
|
||||||
|
status.textContent = 'Not logged into Jellyfin in this browser -- log in in another tab, then reload this page.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/SyncPlus/Stats', { headers: { 'X-Emby-Token': token } })
|
||||||
|
.then(function (r) {
|
||||||
|
if (r.status === 403) throw new Error('403 -- this page requires an admin account');
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function (groups) {
|
||||||
|
status.textContent = 'Updated ' + new Date().toLocaleTimeString();
|
||||||
|
root.innerHTML = groups.length
|
||||||
|
? groups.map(renderGroup).join('')
|
||||||
|
: '<p>No active SyncPlay groups right now.</p>';
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
status.textContent = 'Error: ' + err.message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
syncBtn.addEventListener('click', function () {
|
||||||
|
var token = getToken();
|
||||||
|
if (!token) {
|
||||||
|
syncResult.textContent = 'Not logged in.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncBtn.disabled = true;
|
||||||
|
syncResult.textContent = 'Syncing...';
|
||||||
|
|
||||||
|
fetch('/SyncPlus/Stats/Sync', { method: 'POST', headers: { 'X-Emby-Token': token } })
|
||||||
|
.then(function (r) { return r.json().then(function (body) { return { ok: r.ok, body: body }; }); })
|
||||||
|
.then(function (result) {
|
||||||
|
syncResult.textContent = result.body.Message || (result.ok ? 'Done.' : 'Failed.');
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
syncResult.textContent = 'Error: ' + err.message;
|
||||||
|
})
|
||||||
|
.finally(function () {
|
||||||
|
syncBtn.disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
setInterval(refresh, 1000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of a <see cref="SyncMe"/> call.
|
||||||
|
/// </summary>
|
||||||
|
public class SyncResultDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a human-readable description of what happened.
|
||||||
|
/// </summary>
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets how far back (in ms) the caller's session was moved. 0 if nothing
|
||||||
|
/// happened.
|
||||||
|
/// </summary>
|
||||||
|
public long MovedMs { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stats for one SyncPlay group.
|
||||||
|
/// </summary>
|
||||||
|
public class GroupStatsDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the SyncPlay group id.
|
||||||
|
/// </summary>
|
||||||
|
public Guid GroupId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the group's members.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<MemberStatsDto> Members { get; set; } = Array.Empty<MemberStatsDto>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stats for one SyncPlay group member.
|
||||||
|
/// </summary>
|
||||||
|
public class MemberStatsDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the device id.
|
||||||
|
/// </summary>
|
||||||
|
public string DeviceId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public bool Connected { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the device name.
|
||||||
|
/// </summary>
|
||||||
|
public string? DeviceName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the Jellyfin username.
|
||||||
|
/// </summary>
|
||||||
|
public string? UserName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the name of the item currently playing.
|
||||||
|
/// </summary>
|
||||||
|
public string? NowPlaying { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the raw playback position in ticks.
|
||||||
|
/// </summary>
|
||||||
|
public long? PositionTicks { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the playback position formatted as hh:mm:ss.
|
||||||
|
/// </summary>
|
||||||
|
public string? PositionFormatted { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets how far ahead (in ms) this member is of the group's laggard.
|
||||||
|
/// Never negative -- the laggard itself reports 0.
|
||||||
|
/// </summary>
|
||||||
|
public long? DriftMs { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether playback is paused.
|
||||||
|
/// </summary>
|
||||||
|
public bool? IsPaused { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the playback method (DirectPlay, DirectStream, Transcode).
|
||||||
|
/// </summary>
|
||||||
|
public string? PlayMethod { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the transcoding bitrate in bits/sec, if transcoding.
|
||||||
|
/// </summary>
|
||||||
|
public int? Bitrate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the video codec being sent to this client.
|
||||||
|
/// </summary>
|
||||||
|
public string? VideoCodec { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the audio codec being sent to this client.
|
||||||
|
/// </summary>
|
||||||
|
public string? AudioCodec { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the output container.
|
||||||
|
/// </summary>
|
||||||
|
public string? Container { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether the video stream is being copied
|
||||||
|
/// untouched (not re-encoded).
|
||||||
|
/// </summary>
|
||||||
|
public bool? IsVideoDirect { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether the audio stream is being copied
|
||||||
|
/// untouched (not re-encoded).
|
||||||
|
/// </summary>
|
||||||
|
public bool? IsAudioDirect { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets why this client is transcoding, if it is.
|
||||||
|
/// </summary>
|
||||||
|
public string? TranscodeReasons { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decorates the built-in <see cref="SyncPlayManager"/> to track group membership by
|
||||||
|
/// device id, so <see cref="SyncPlusStatsController"/> 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 <see cref="ISyncPlayManager"/>
|
||||||
|
/// via <see cref="PluginServiceRegistrator"/> -- 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.
|
||||||
|
/// </summary>
|
||||||
|
public class SyncPlusSyncPlayManager : ISyncPlayManager
|
||||||
|
{
|
||||||
|
private readonly SyncPlayManager _inner;
|
||||||
|
private readonly IGroupMembershipStore _membershipStore;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="SyncPlusSyncPlayManager"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="inner">The real, built-in SyncPlay manager this decorates.</param>
|
||||||
|
/// <param name="membershipStore">Tracks group membership.</param>
|
||||||
|
public SyncPlusSyncPlayManager(SyncPlayManager inner, IGroupMembershipStore membershipStore)
|
||||||
|
{
|
||||||
|
_inner = inner;
|
||||||
|
_membershipStore = membershipStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public GroupInfoDto NewGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = _inner.NewGroup(session, request, cancellationToken);
|
||||||
|
_membershipStore.SessionJoined(result.GroupId, session.DeviceId);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void JoinGroup(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_inner.JoinGroup(session, request, cancellationToken);
|
||||||
|
_membershipStore.SessionJoined(request.GroupId, session.DeviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public List<GroupInfoDto> ListGroups(SessionInfo session, ListGroupsRequest request)
|
||||||
|
=> _inner.ListGroups(session, request);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public GroupInfoDto GetGroup(SessionInfo session, Guid groupId)
|
||||||
|
=> _inner.GetGroup(session, groupId);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void HandleRequest(SessionInfo session, IGroupPlaybackRequest request, CancellationToken cancellationToken)
|
||||||
|
=> _inner.HandleRequest(session, request, cancellationToken);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool IsUserActive(Guid userId)
|
||||||
|
=> _inner.IsUserActive(userId);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_inner.Dispose();
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user