Add SyncPlay stats-for-nerds page, manual sync button, and release pipeline
Build Check / build (push) Successful in 29s
Release Plugin / release (push) Successful in 22s

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:
2026-07-09 17:06:46 +02:00
co-authored by Claude Sonnet 5
parent cf8badcdda
commit b83074d55f
21 changed files with 1435 additions and 21 deletions
+29
View File
@@ -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
+90
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
bin/
obj/
dev/config/
dev/plugins/*/*.dll
lib/
release/
+27
View File
@@ -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
+178 -21
View File
@@ -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 <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
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 `<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)
- Exact client compatibility target for v1 -- web client only, or also
+21
View File
@@ -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.
+18
View File
@@ -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"
+15
View File
@@ -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"
+48
View File
@@ -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"
}
+85
View File
@@ -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()
+20
View File
@@ -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 &lt;script&gt; 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>
+54
View File
@@ -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>&lt;script&gt;</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);
}
}