6 Commits
Author SHA1 Message Date
seer 5015ffe2b4 FIx versioning
Release Plugin / release (push) Failing after 19s
2026-07-10 14:13:17 +02:00
seerandClaude Fable 5 697b934108 Collapse cog menu to a single item to stop the sheet overflowing
Release Plugin / release (push) Failing after 21s
Two injected menu items made the settings action sheet tall enough to
run off the bottom of the screen (jellyfin measures/positions the sheet
before our async injection adds height). Now inject just one item,
"SyncPlay stats", and move the sync button back into the panel it opens
alongside the drift readout -- so the sheet barely grows and both
actions live in our own overflow-proof UI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 14:06:57 +02:00
seerandClaude Fable 5 64a30bfa64 Move in-player entry point into the player settings cog menu
Replaces the floating SyncPlus pill with two items injected into the
cog action sheet: "Sync me to group" (immediate one-shot sync, result
shown as a transient toast) and "SyncPlay stats" (toggles the floating
drift panel, now with its own close button). A MutationObserver appends
the items when the sheet appears within 1.5s of a .btnVideoOsdSettings
click; markup mirrors the native actionSheetMenuItem structure verified
in the bundled 10.11.6 client, and the sheet's own delegated handler
auto-closes it on our items like any native entry. Worst-case failure
on a future jellyfin-web rename is the items silently not appearing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 14:01:49 +02:00
seerandClaude Fable 5 268a6ed2d0 Add in-player SyncPlus overlay via served-index.html script injection
An IStartupFilter-registered middleware intercepts /web/index.html
responses and injects a script tag for /SyncPlus/Stats/Client.js --
response-level injection rather than the write-to-disk approach other
plugins use, since index.html is root-owned in the linuxserver image
while Jellyfin runs unprivileged. The overlay floats its own fixed
pill/panel (no dependency on jellyfin-web's minified OSD DOM), shows
per-member drift for the caller's own group via the new non-admin
/SyncPlus/Stats/Mine endpoint, and reuses the existing one-shot
/SyncPlus/Stats/Sync action.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 13:55:51 +02:00
seerandClaude Sonnet 5 8dadef4184 Document release distribution as single public repo, not split
Build Check / build (push) Successful in 19s
The public/private repo split got reverted (see previous commit) in
favor of just making this repo public directly. PLAN.md's Release
distribution section still described the old "later, once stable"
placeholder from before either version was built -- update it to match
what's actually running.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 17:23:52 +02:00
seer 7245bcf1ef Revert "Split release publishing into a separate public distribution repo"
This reverts commit 1d2246c5e2.
2026-07-09 17:23:11 +02:00
9 changed files with 527 additions and 215 deletions
+29 -24
View File
@@ -1,14 +1,5 @@
name: Release Plugin name: Release Plugin
# Releases are published to a *separate public* distribution repo, not this one.
# Gitea has no way to make individual release assets public while the source repo
# stays private -- repo visibility is all-or-nothing, confirmed against Gitea's own
# docs/issue tracker -- so the actual plugin zip + manifest.json live in
# PUBLIC_REPO_NAME below, kept in sync by this workflow. This (private source) repo
# never needs to be public for Jellyfin installs to work.
env:
PUBLIC_REPO_NAME: JellyfinSyncPlus-repo
on: on:
push: push:
tags: tags:
@@ -32,6 +23,7 @@ jobs:
id: meta id: meta
run: | run: |
echo REPO_OWNER=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $1}') >> $GITHUB_OUTPUT 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 echo VERSION=$(echo ${GITHUB_REF_NAME} | sed 's/^v//') >> $GITHUB_OUTPUT
cat $GITHUB_OUTPUT cat $GITHUB_OUTPUT
@@ -46,40 +38,53 @@ jobs:
docker cp jf-extract:/usr/lib/jellyfin/bin/Emby.Server.Implementations.dll lib/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 docker rm jf-extract
- name: Build and package - name: Build, package, update manifest
id: package
env: env:
VERSION: ${{ steps.meta.outputs.VERSION }} VERSION: ${{ steps.meta.outputs.VERSION }}
SERVER_URL: ${{ github.server_url }} SERVER_URL: ${{ github.server_url }}
PUBLIC_REPO_OWNER: ${{ steps.meta.outputs.REPO_OWNER }} REPO_OWNER: ${{ steps.meta.outputs.REPO_OWNER }}
PUBLIC_REPO_NAME: ${{ env.PUBLIC_REPO_NAME }} REPO_NAME: ${{ steps.meta.outputs.REPO_NAME }}
run: dev/package-release.sh run: dev/package-release.sh
- name: Create release and upload zip on the public repo - name: Create Gitea release and upload zip
env: env:
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }} GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
SERVER_URL: ${{ github.server_url }} SERVER_URL: ${{ github.server_url }}
PUBLIC_REPO_OWNER: ${{ steps.meta.outputs.REPO_OWNER }} REPO_OWNER: ${{ steps.meta.outputs.REPO_OWNER }}
REPO_NAME: ${{ steps.meta.outputs.REPO_NAME }}
VERSION: ${{ steps.meta.outputs.VERSION }} VERSION: ${{ steps.meta.outputs.VERSION }}
TAG: ${{ github.ref_name }} TAG: ${{ github.ref_name }}
ZIP_PATH: ${{ steps.package.outputs.ZIP_PATH }}
run: | run: |
RELEASE_ID=$(curl -sf -X POST \ RELEASE_ID=$(curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \ -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"body\":\"See build.yaml changelog / manifest.json for this version's notes.\"}" \ -d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"body\":\"See build.yaml changelog / manifest.json for this version's notes.\"}" \
"${SERVER_URL}/api/v1/repos/${PUBLIC_REPO_OWNER}/${PUBLIC_REPO_NAME}/releases" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])') "${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 \ curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \ -H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${ZIP_PATH}" \ -F "attachment=@release/JellyfinSyncPlus_${VERSION}.zip" \
"${SERVER_URL}/api/v1/repos/${PUBLIC_REPO_OWNER}/${PUBLIC_REPO_NAME}/releases/${RELEASE_ID}/assets?name=JellyfinSyncPlus_${VERSION}.zip" "${SERVER_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=JellyfinSyncPlus_${VERSION}.zip"
- name: Publish manifest.json to the public repo # 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: env:
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }} GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
SERVER_URL: ${{ github.server_url }} SERVER_URL: ${{ github.server_url }}
PUBLIC_REPO_OWNER: ${{ steps.meta.outputs.REPO_OWNER }} REPO_OWNER: ${{ steps.meta.outputs.REPO_OWNER }}
REPO_NAME: ${{ steps.meta.outputs.REPO_NAME }}
VERSION: ${{ steps.meta.outputs.VERSION }} VERSION: ${{ steps.meta.outputs.VERSION }}
CHECKSUM: ${{ steps.package.outputs.CHECKSUM }} run: |
SOURCE_URL: ${{ steps.package.outputs.SOURCE_URL }} cp manifest.json /tmp/manifest.json
run: dev/publish-manifest.sh 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
+66 -41
View File
@@ -153,51 +153,34 @@ Two different loops -- do not conflate them:
versioned zip + `manifest.json`, host via Gitea, add the repo URL in versioned zip + `manifest.json`, host via Gitea, add the repo URL in
Jellyfin's dashboard. Mirrors MR-Discord's tag -> Gitea Actions -> build Jellyfin's dashboard. Mirrors MR-Discord's tag -> Gitea Actions -> build
pipeline, just producing a plugin zip instead of a Docker image. pipeline, just producing a plugin zip instead of a Docker image.
- **Source repo (`JellyfinSyncPlus`) stays private; a separate public repo - **Repo (`JellyfinSyncPlus`) itself is public** -- briefly tried a
(`JellyfinSyncPlus-repo`) holds only `manifest.json` and release zips.** separate private-source/public-distribution repo split (Gitea has no
Confirmed via Gitea's own docs/issue tracker: there's no way to make way to make individual release assets public on a private repo; repo
individual release assets public while the repo itself stays private -- visibility is all-or-nothing there), but decided to just make this repo
repo visibility is all-or-nothing, covering raw files, releases, and API public instead rather than maintain two repos. Manifest + releases live
access alike. So the two are fully decoupled: CI builds from the private directly on this repo.
source repo (has the checkout + build context) but publishes the
release and updates the manifest on the separate public repo, using the
same `RELEASE_TOKEN` (Gitea personal access tokens are user-scoped, not
repo-scoped, so one token works against both repos as long as the
owning account has write access to each).
- `dev/package-release.sh` -- builds Release config, zips the DLL, - `dev/package-release.sh` -- builds Release config, zips the DLL,
computes its MD5 checksum, derives the release download URL (pointing computes its MD5 checksum, updates `manifest.json` (schema-verified
at the *public* repo). Live-tested locally twice (once before, once against a real published Jellyfin plugin repo's actual file). Live-
after the public/private split) -- produces a real, valid zip + a tested locally.
`manifest.json` schema-verified against a real published Jellyfin - `dev/update_manifest.py` -- the manifest merge logic package-release.sh
plugin repo's actual file, not just assumed from memory. calls; reads plugin metadata/changelog from `build.yaml`.
- `dev/publish-manifest.sh` -- fetches the public repo's current
`manifest.json` via Gitea's Contents API (or starts fresh on the very
first release), merges in this version via `dev/update_manifest.py`,
and pushes it back via the same API. No git clone of the public repo
needed.
- `.github/workflows/release.yml` -- on a `v*` tag push: builds - `.github/workflows/release.yml` -- on a `v*` tag push: builds
(including `dev/extract-private-refs.sh`'s CI equivalent, pulling the (including pulling the two private SDK assemblies straight from the
two private SDK assemblies straight from the pinned pinned `linuxserver/jellyfin:10.11.6` image via `docker cp`, mirroring
`linuxserver/jellyfin:10.11.6` image via `docker cp`), packages, `dev/extract-private-refs.sh` for CI), packages, creates a Gitea
creates a Gitea release + uploads the zip on the *public* repo, then release with the zip attached, commits the updated `manifest.json`
publishes the manifest there too. back to `master`.
- `.github/workflows/build.yml` -- plain build-check on every push/PR to - `.github/workflows/build.yml` -- plain build-check on every push/PR to
`master`, mirroring MR-Discord's `build.yml` pattern. `master`, mirroring MR-Discord's `build.yml` pattern.
- **Live-verified 2026-07-09, partially**: tagged and pushed `v0.2.0.0` - **Live-verified 2026-07-09**: tagged and pushed `v0.2.0.0` -- confirmed
against the *first* version of this pipeline (before the public/private the workflow ran, created a real Gitea release with the zip attached,
split existed) -- confirmed the workflow actually ran, created a real and committed a real `manifest.json` back to `master`. Manifest URL:
Gitea release with the zip attached, and committed a real `https://gitea.mrcynic.site/seer/JellyfinSyncPlus/raw/branch/master/manifest.json`.
`manifest.json`. That run published to the private source repo itself, - Once installed, Jellyfin has no hot-reload -- same as local dev, a
which is exactly the mistake the public/private split above exists to server restart is required. On the real k8s deployment that's the
fix; the updated, split version hasn't had a live tag-triggered CI run "confirm with the user first" milestone action from the "Dev / test /
yet as of this note -- the local packaging half is tested, the iterate workflow" section above, not a routine step.
`publish-manifest.sh` Contents API calls are syntax/logic-checked but
not yet exercised against a real Gitea instance.
- Once a plugin is actually installed from this repo, Jellyfin has no
hot-reload -- same as local dev, a server restart is required. On the
real k8s deployment that's the "confirm with the user first" milestone
action from the "Dev / test / iterate workflow" section above, not a
routine step.
## Known friction / risks (flagged going in, not discovered later) ## Known friction / risks (flagged going in, not discovered later)
@@ -378,6 +361,48 @@ modes the reverted features did.
real drifted second device (only one browser session available this real drifted second device (only one browser session available this
round, same recurring constraint as everything else that needs two real round, same recurring constraint as everything else that needs two real
logins). logins).
- **In-player overlay added 2026-07-09.** User-requested (correctly pushing
back on an earlier "not possible without patching jellyfin-web" claim --
plugins like InPlayerEpisodePreview prove the pattern exists): the stats
panel + sync button now also appear *inside the video player page*, not
just on the standalone stats page.
- Mechanism: an `IStartupFilter` registered from plugin DI adds
`SyncPlusIndexInjectionMiddleware` to the front of the pipeline, which
intercepts responses for `/web/` and `/web/index.html` and injects a
`<script src="/SyncPlus/Stats/Client.js" defer>` tag before `</body>`
**in the served response only** -- deliberately NOT the
write-to-index.html-on-disk approach other plugins default to, because
in the linuxserver image (dev and the real k8s deployment alike)
index.html is root-owned while Jellyfin runs unprivileged, so disk
writes fail with permission errors there (the known issue
InPlayerEpisodePreview's README warns about). Response interception has
no permission problem and leaves nothing behind on uninstall. The
middleware also strips `Accept-Encoding` on those requests (so
downstream compression can't garble the `</body>` marker) and drops
`ETag`/`Last-Modified` on modified responses (so browsers can't
cache-revalidate back to an uninjected copy).
- `GET /SyncPlus/Stats/Client.js` serves the overlay script
(`[AllowAnonymous]` -- script tags fetch without auth; the script itself
only calls auth-gated endpoints via `window.ApiClient.accessToken()`,
available because the script runs inside the real SPA).
- `GET /SyncPlus/Stats/Mine` -- new non-admin endpoint returning only the
*caller's own group's* stats (regular viewers aren't admins, and the
admin-gated all-groups endpoint would leak other users' sessions).
Returns `{"Group": null}`-shaped response when not in a group (nulls
are omitted by Jellyfin's JSON serializer, so the body is literally
`{}` -- the overlay JS handles `undefined` fine).
- Overlay design: deliberately does NOT hook into jellyfin-web's OSD
button DOM (minified class names churn across releases) -- it floats
its own fixed-position "SyncPlus" pill + panel, shown only while a
`<video>` element exists on the page. Survives web client updates at
the cost of not looking native.
- **Server-side verified 2026-07-09**: script tag confirmed present in
served `/web/index.html` and `/web/` (including with
`Accept-Encoding: gzip, br` requests), `Client.js` serves as
`application/javascript` and passes `node --check`, `Mine` returns 200
with token / 401 without. **Browser-side behavior (overlay appearing
during playback, panel polling, sync button) handed to the user to
test** -- not yet verified as of this note.
## Open questions (revisit before/at the relevant phase, not now) ## Open questions (revisit before/at the relevant phase, not now)
+2 -2
View File
@@ -1,6 +1,6 @@
name: "JellyfinSyncPlus" name: "JellyfinSyncPlus"
guid: "267dbfe9-bb9c-4eeb-97aa-f0449283cfe6" guid: "267dbfe9-bb9c-4eeb-97aa-f0449283cfe6"
version: "0.2.0.0" version: "0.3.0.0"
targetAbi: "10.11.6.0" targetAbi: "10.11.6.0"
framework: "net9.0" framework: "net9.0"
owner: "cynic" owner: "cynic"
@@ -16,6 +16,6 @@ category: "General"
artifacts: artifacts:
- "JellyfinSyncPlus.dll" - "JellyfinSyncPlus.dll"
changelog: > changelog: >
0.2.0.0: SyncPlay stats-for-nerds page and manual one-shot "sync me to 0.3.0.0: SyncPlay stats-for-nerds page and manual one-shot "sync me to
group" button. No automatic playback intervention. group" button. No automatic playback intervention.
0.1.0.0: Phase 0 scaffold. Trivial plugin, no functionality yet. 0.1.0.0: Phase 0 scaffold. Trivial plugin, no functionality yet.
+17 -22
View File
@@ -1,21 +1,18 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Builds and packages a tagged release. Used by .github/workflows/release.yml; safe to # Builds, packages, and updates manifest.json for a tagged release. Used by
# run locally too for a dry run. Does NOT touch manifest.json -- that's published # .github/workflows/release.yml; safe to run locally too for a dry run.
# separately to the public distribution repo by dev/publish-manifest.sh, since the zip
# this produces gets uploaded there too (release assets on a private repo can't be made
# public on their own in Gitea; see PLAN.md "Release distribution").
# #
# Required env vars: # Required env vars:
# VERSION -- e.g. 0.2.0.0 (must match build.yaml's version) # VERSION -- e.g. 0.3.0.0 (must match build.yaml's version)
# SERVER_URL -- e.g. https://gitea.mrcynic.site # SERVER_URL -- e.g. https://gitea.mrcynic.site
# PUBLIC_REPO_OWNER -- Gitea org/user the *public* distribution repo lives under # REPO_OWNER -- Gitea org/user the repo lives under
# PUBLIC_REPO_NAME -- the public distribution repo's name (holds releases + manifest.json) # REPO_NAME -- Gitea repo name
set -euo pipefail set -euo pipefail
: "${VERSION:?VERSION env var required, e.g. 0.2.0.0}" : "${VERSION:?VERSION env var required, e.g. 0.3.0.0}"
: "${SERVER_URL:?SERVER_URL env var required, e.g. https://gitea.mrcynic.site}" : "${SERVER_URL:?SERVER_URL env var required, e.g. https://gitea.mrcynic.site}"
: "${PUBLIC_REPO_OWNER:?PUBLIC_REPO_OWNER env var required}" : "${REPO_OWNER:?REPO_OWNER env var required}"
: "${PUBLIC_REPO_NAME:?PUBLIC_REPO_NAME env var required}" : "${REPO_NAME:?REPO_NAME env var required}"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT" cd "$ROOT"
@@ -37,17 +34,15 @@ rm -f "$ZIP_PATH"
(cd src/JellyfinSyncPlus/bin/Release/net9.0 && zip -j "$ROOT/$ZIP_PATH" JellyfinSyncPlus.dll) (cd src/JellyfinSyncPlus/bin/Release/net9.0 && zip -j "$ROOT/$ZIP_PATH" JellyfinSyncPlus.dll)
CHECKSUM=$(md5sum "$ZIP_PATH" | awk '{print $1}') CHECKSUM=$(md5sum "$ZIP_PATH" | awk '{print $1}')
SOURCE_URL="${SERVER_URL}/${PUBLIC_REPO_OWNER}/${PUBLIC_REPO_NAME}/releases/download/v${VERSION}/${ZIP_NAME}" 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 "Packaged $ZIP_PATH"
echo "Checksum: $CHECKSUM" echo "Checksum: $CHECKSUM"
echo "Source URL: $SOURCE_URL" echo "Source URL: $SOURCE_URL"
# For the next workflow step to pick up without re-deriving them (no-op outside CI).
if [ -n "${GITHUB_OUTPUT:-}" ]; then
{
echo "CHECKSUM=${CHECKSUM}"
echo "SOURCE_URL=${SOURCE_URL}"
echo "ZIP_PATH=${ZIP_PATH}"
} >> "$GITHUB_OUTPUT"
fi
-65
View File
@@ -1,65 +0,0 @@
#!/usr/bin/env bash
# Fetches the current manifest.json from the public distribution repo (or starts fresh
# if this is the first release), merges in this build's version entry, and pushes it
# back -- all via Gitea's Contents API, so this never needs a git clone of the public
# repo. Keeps the private source repo and the public distribution repo fully decoupled.
#
# Required env vars:
# GITEA_TOKEN, SERVER_URL, PUBLIC_REPO_OWNER, PUBLIC_REPO_NAME, VERSION, CHECKSUM, SOURCE_URL
set -euo pipefail
: "${GITEA_TOKEN:?}"
: "${SERVER_URL:?}"
: "${PUBLIC_REPO_OWNER:?}"
: "${PUBLIC_REPO_NAME:?}"
: "${VERSION:?}"
: "${CHECKSUM:?}"
: "${SOURCE_URL:?}"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
API="${SERVER_URL}/api/v1/repos/${PUBLIC_REPO_OWNER}/${PUBLIC_REPO_NAME}/contents/manifest.json"
WORKDIR=$(mktemp -d)
trap 'rm -rf "$WORKDIR"' EXIT
HTTP_CODE=$(curl -s -o "$WORKDIR/existing.json" -w "%{http_code}" \
-H "Authorization: token ${GITEA_TOKEN}" "${API}?ref=master")
if [ "$HTTP_CODE" = "200" ]; then
SHA=$(python3 -c 'import json; print(json.load(open("'"$WORKDIR"'/existing.json"))["sha"])')
python3 -c 'import json,base64
d = json.load(open("'"$WORKDIR"'/existing.json"))
open("'"$WORKDIR"'/manifest.json", "wb").write(base64.b64decode(d["content"]))'
echo "Fetched existing manifest.json (sha ${SHA})"
else
SHA=""
echo "[]" > "$WORKDIR/manifest.json"
echo "No existing manifest.json in ${PUBLIC_REPO_OWNER}/${PUBLIC_REPO_NAME} -- starting fresh (HTTP $HTTP_CODE)"
fi
python3 "$ROOT/dev/update_manifest.py" \
--build-yaml "$ROOT/build.yaml" \
--manifest "$WORKDIR/manifest.json" \
--version "$VERSION" \
--checksum "$CHECKSUM" \
--source-url "$SOURCE_URL"
CONTENT_B64=$(base64 -w0 "$WORKDIR/manifest.json")
python3 -c 'import json,sys
body = {"content": sys.argv[1], "message": "Release " + sys.argv[2], "branch": "master"}
if sys.argv[3]:
body["sha"] = sys.argv[3]
json.dump(body, open(sys.argv[4], "w"))' "$CONTENT_B64" "$VERSION" "$SHA" "$WORKDIR/body.json"
METHOD="POST"
if [ -n "$SHA" ]; then
METHOD="PUT"
fi
curl -sf -X "$METHOD" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "@$WORKDIR/body.json" \
"$API" > /dev/null
echo "Published manifest.json version ${VERSION} to ${PUBLIC_REPO_OWNER}/${PUBLIC_REPO_NAME}"
+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.3.0.0",
"changelog": "0.3.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.3.0.0/JellyfinSyncPlus_0.3.0.0.zip",
"checksum": "34e384fc5914e2c459f52a9a7bfd91a5",
"timestamp": "2026-07-09T15:11:18Z"
}
]
}
]
@@ -2,6 +2,7 @@ using Jellyfin.Plugin.SyncPlus.SyncPlay;
using MediaBrowser.Controller; using MediaBrowser.Controller;
using MediaBrowser.Controller.Plugins; using MediaBrowser.Controller.Plugins;
using MediaBrowser.Controller.SyncPlay; using MediaBrowser.Controller.SyncPlay;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using ConcreteSyncPlayManager = Emby.Server.Implementations.SyncPlay.SyncPlayManager; using ConcreteSyncPlayManager = Emby.Server.Implementations.SyncPlay.SyncPlayManager;
@@ -20,5 +21,10 @@ public class PluginServiceRegistrator : IPluginServiceRegistrator
// RegisterServices runs after core's, so this registration wins for resolution. // RegisterServices runs after core's, so this registration wins for resolution.
serviceCollection.AddSingleton<ConcreteSyncPlayManager>(); serviceCollection.AddSingleton<ConcreteSyncPlayManager>();
serviceCollection.AddSingleton<ISyncPlayManager, SyncPlusSyncPlayManager>(); serviceCollection.AddSingleton<ISyncPlayManager, SyncPlusSyncPlayManager>();
// Injects the in-player overlay script tag into served index.html -- see
// SyncPlusIndexInjection.cs for why this is a startup filter and why it
// intercepts responses instead of writing to the file on disk.
serviceCollection.AddSingleton<IStartupFilter, SyncPlusIndexInjectionStartupFilter>();
} }
} }
@@ -0,0 +1,119 @@
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SyncPlus.SyncPlay;
/// <summary>
/// Registers <see cref="SyncPlusIndexInjectionMiddleware"/> at the front of the request
/// pipeline. An <see cref="IStartupFilter"/> registered in DI is the one seam a plugin
/// has for adding middleware -- plugins can't touch Jellyfin's own <c>Startup.Configure</c>,
/// but startup filters are resolved from the final service provider when the pipeline is
/// built, and plugin <c>RegisterServices</c> runs early enough to land there. Same
/// technique the "File Transformation" plugin ecosystem uses.
/// </summary>
public class SyncPlusIndexInjectionStartupFilter : IStartupFilter
{
/// <inheritdoc />
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
=> app =>
{
app.UseMiddleware<SyncPlusIndexInjectionMiddleware>();
next(app);
};
}
/// <summary>
/// Injects a <c>&lt;script&gt;</c> tag for the plugin's in-player overlay
/// (<see cref="SyncPlusStatsController.GetClientScript"/>) into the *served*
/// <c>index.html</c> response, on the fly. Deliberately does not write to the file on
/// disk: in the linuxserver image (dev and the real k8s deployment alike) index.html is
/// owned by root while Jellyfin runs as an unprivileged user, so the disk-write approach
/// other plugins default to (e.g. InPlayerEpisodePreview) fails with permission errors
/// there. Intercepting the response instead has no permission problem and leaves nothing
/// behind if the plugin is removed.
/// </summary>
public class SyncPlusIndexInjectionMiddleware
{
private const string ScriptTag = "<script src=\"/SyncPlus/Stats/Client.js\" defer></script>";
private readonly RequestDelegate _next;
private readonly ILogger<SyncPlusIndexInjectionMiddleware> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="SyncPlusIndexInjectionMiddleware"/> class.
/// </summary>
/// <param name="next">The next middleware in the pipeline.</param>
/// <param name="logger">Instance of the <see cref="ILogger{SyncPlusIndexInjectionMiddleware}"/> interface.</param>
public SyncPlusIndexInjectionMiddleware(RequestDelegate next, ILogger<SyncPlusIndexInjectionMiddleware> logger)
{
_next = next;
_logger = logger;
}
/// <summary>
/// Intercepts index.html responses and injects the overlay script tag.
/// </summary>
/// <param name="context">The HTTP context.</param>
/// <returns>A task.</returns>
public async Task InvokeAsync(HttpContext context)
{
var path = context.Request.Path.Value ?? string.Empty;
var isIndex = path.Equals("/web/", StringComparison.OrdinalIgnoreCase)
|| path.Equals("/web/index.html", StringComparison.OrdinalIgnoreCase);
if (!isIndex)
{
await _next(context).ConfigureAwait(false);
return;
}
// Downstream middleware would otherwise gzip/brotli the body before it gets back
// to us, making the </body> marker unfindable. Dropping Accept-Encoding for this
// one request keeps the body plain; index.html is ~5KB, the loss is irrelevant.
context.Request.Headers.Remove("Accept-Encoding");
var originalBody = context.Response.Body;
using var buffer = new MemoryStream();
context.Response.Body = buffer;
try
{
await _next(context).ConfigureAwait(false);
var isHtml = context.Response.ContentType?.Contains("text/html", StringComparison.OrdinalIgnoreCase) ?? false;
if (context.Response.StatusCode == StatusCodes.Status200OK && isHtml)
{
var html = Encoding.UTF8.GetString(buffer.ToArray());
var injected = html.Replace("</body>", ScriptTag + "</body>", StringComparison.OrdinalIgnoreCase);
var bytes = Encoding.UTF8.GetBytes(injected);
if (injected.Length == html.Length)
{
_logger.LogWarning("SyncPlus: served index.html contains no </body> tag, overlay script not injected");
}
// The stale validators describe the unmodified file; leaving them would let
// a browser cache-revalidate its way back to an uninjected copy.
context.Response.Headers.Remove("ETag");
context.Response.Headers.Remove("Last-Modified");
context.Response.ContentLength = bytes.Length;
await originalBody.WriteAsync(bytes).ConfigureAwait(false);
}
else
{
buffer.Position = 0;
await buffer.CopyToAsync(originalBody).ConfigureAwait(false);
}
}
finally
{
context.Response.Body = originalBody;
}
}
}
@@ -65,6 +65,22 @@ public class SyncPlusStatsController : ControllerBase
return Content(StatsPageHtml, "text/html"); return Content(StatsPageHtml, "text/html");
} }
/// <summary>
/// Serves the in-player overlay script. A script tag pointing here gets injected
/// into the served <c>index.html</c> by <see cref="SyncPlusIndexInjectionMiddleware"/>,
/// so this runs inside the real jellyfin-web SPA with access to <c>window.ApiClient</c>
/// -- same reason the standalone stats page can read the login token. Anonymous
/// because script tags are fetched without auth headers; the script itself only
/// calls auth-gated endpoints.
/// </summary>
/// <returns>The client-side overlay script.</returns>
[HttpGet("Client.js")]
[AllowAnonymous]
public ContentResult GetClientScript()
{
return Content(ClientScriptJs, "application/javascript");
}
/// <summary> /// <summary>
/// Gets a live snapshot of every active SyncPlay group's members and their drift /// Gets a live snapshot of every active SyncPlay group's members and their drift
/// relative to whoever in the group is furthest behind. /// relative to whoever in the group is furthest behind.
@@ -74,76 +90,109 @@ public class SyncPlusStatsController : ControllerBase
[Authorize(Policy = Policies.RequiresElevation)] [Authorize(Policy = Policies.RequiresElevation)]
public ActionResult<IReadOnlyList<GroupStatsDto>> GetStats() public ActionResult<IReadOnlyList<GroupStatsDto>> GetStats()
{ {
var sessionsByDevice = _sessionManager.Sessions var sessionsByDevice = GetSessionsByDevice();
.Where(s => !string.IsNullOrEmpty(s.DeviceId))
.GroupBy(s => s.DeviceId)
.ToDictionary(g => g.Key, g => g.First());
var result = new List<GroupStatsDto>(); var result = new List<GroupStatsDto>();
foreach (var (groupId, deviceIds) in _membershipStore.GetAllGroups()) foreach (var (groupId, deviceIds) in _membershipStore.GetAllGroups())
{ {
var members = new List<MemberStatsDto>(); result.Add(BuildGroupStats(groupId, deviceIds, sessionsByDevice));
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; return result;
} }
/// <summary>
/// Gets the live stats for the calling device's own SyncPlay group only. Unlike
/// <see cref="GetStats"/> this is open to any authenticated user, because it only
/// ever exposes the group the caller is themselves a member of -- it's what the
/// in-player overlay (served by <see cref="GetClientScript"/>) polls, and regular
/// viewers aren't admins.
/// </summary>
/// <returns>The caller's group stats, with <c>Group</c> null when not in a group.</returns>
[HttpGet("Mine")]
[Authorize]
public ActionResult<MyGroupStatsDto> GetMyStats()
{
var deviceId = User.Claims.FirstOrDefault(c => c.Type == DeviceIdClaimType)?.Value;
var groupId = string.IsNullOrEmpty(deviceId) ? null : _membershipStore.GetGroupForDevice(deviceId);
if (groupId is null || !_membershipStore.GetAllGroups().TryGetValue(groupId.Value, out var deviceIds))
{
return new MyGroupStatsDto { Group = null };
}
return new MyGroupStatsDto { Group = BuildGroupStats(groupId.Value, deviceIds, GetSessionsByDevice()) };
}
private Dictionary<string, SessionInfo> GetSessionsByDevice()
=> _sessionManager.Sessions
.Where(s => !string.IsNullOrEmpty(s.DeviceId))
.GroupBy(s => s.DeviceId)
.ToDictionary(g => g.Key, g => g.First());
private static GroupStatsDto BuildGroupStats(
Guid groupId,
IReadOnlyCollection<string> deviceIds,
Dictionary<string, SessionInfo> sessionsByDevice)
{
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()
});
}
return new GroupStatsDto
{
GroupId = groupId,
Members = members
};
}
/// <summary> /// <summary>
/// One-shot: seeks the calling device's own session to match whoever in its SyncPlay /// 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 /// group is furthest behind (never the other direction -- if the caller is themselves
@@ -385,6 +434,164 @@ public class SyncPlusStatsController : ControllerBase
</html> </html>
"""; """;
// The in-player UI. Entry point is jellyfin-web's own player settings (cog) menu:
// we watch for the action sheet it opens and append a single native-looking menu
// item ("SyncPlay stats") that opens our own floating panel holding both the drift
// readout and the sync button. Just one item on purpose -- the sheet is measured and
// positioned before our async injection runs, so every added item risks pushing it
// off the bottom of the screen (which is exactly what happened with two). Depends on
// a small, verified slice of the minified client's DOM (.btnVideoOsdSettings,
// .actionSheet, .actionSheetScroller, .actionSheetMenuItem -- all confirmed present
// in the bundled 10.11.6 client, including the sheet's own delegated click handler
// that auto-closes it on any .actionSheetMenuItem click). If a future jellyfin-web
// rename breaks these, the failure mode is just "the item doesn't appear" -- nothing
// errors, and the standalone stats page still works.
private const string ClientScriptJs = """
(function () {
'use strict';
if (window.__syncPlusInjected) { return; }
window.__syncPlusInjected = true;
function token() {
try { return window.ApiClient ? window.ApiClient.accessToken() : null; } catch (e) { return null; }
}
var panel = document.createElement('div');
panel.id = 'syncPlusPanel';
panel.style.cssText = 'position:fixed;top:70px;left:12px;z-index:99999;display:none;font-family:sans-serif;font-size:13px;color:#ddd;background:rgba(0,0,0,.8);border:1px solid rgba(255,255,255,.15);border-radius:6px;padding:10px 12px;min-width:260px;';
panel.innerHTML =
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">' +
'<span style="color:#999;">SyncPlay stats</span>' +
'<button id="syncPlusClose" style="background:none;border:none;color:#999;cursor:pointer;font-size:14px;padding:0 2px;">&#10005;</button>' +
'</div>' +
'<div id="syncPlusRows">Not in a SyncPlay group.</div>' +
'<button id="syncPlusSyncBtn" style="margin-top:10px;background:#00a4dc;color:#fff;border:none;border-radius:3px;padding:6px 12px;cursor:pointer;font-size:12px;">Sync me to group</button>' +
'<div id="syncPlusResult" style="margin-top:6px;color:#999;font-size:12px;min-height:1em;"></div>';
document.body.appendChild(panel);
var rows = panel.querySelector('#syncPlusRows');
var result = panel.querySelector('#syncPlusResult');
var pollHandle = null;
function closePanel() {
panel.style.display = 'none';
if (pollHandle) { clearInterval(pollHandle); pollHandle = null; }
}
panel.querySelector('#syncPlusClose').addEventListener('click', closePanel);
function driftColor(ms) {
if (ms === null || ms === undefined) { return '#999'; }
if (ms >= 3000) { return '#ff6b6b'; }
if (ms >= 1000) { return '#ffb86b'; }
return '#6bd68b';
}
function render(group) {
if (!group) {
rows.textContent = 'Not in a SyncPlay group.';
return;
}
var html = '';
(group.Members || []).forEach(function (m) {
if (!m.Connected) { return; }
var drift = m.DriftMs === null || m.DriftMs === undefined ? '-' : m.DriftMs + ' ms';
html += '<div style="display:flex;justify-content:space-between;gap:12px;padding:2px 0;">' +
'<span>' + (m.UserName || '?') + ' (' + (m.PlayMethod || '-') + (m.IsPaused ? ', paused' : '') + ')</span>' +
'<span style="color:' + driftColor(m.DriftMs) + ';">' + drift + '</span></div>';
});
rows.innerHTML = html || 'No connected members.';
}
function refresh() {
var t = token();
if (!t) { return; }
fetch('/SyncPlus/Stats/Mine', { headers: { 'X-Emby-Token': t } })
.then(function (r) { if (!r.ok) { throw new Error('HTTP ' + r.status); } return r.json(); })
.then(function (d) { render(d.Group); })
.catch(function (e) { rows.textContent = 'Stats error: ' + e.message; });
}
function togglePanel() {
if (panel.style.display !== 'none') { closePanel(); return; }
result.textContent = '';
panel.style.display = 'block';
refresh();
pollHandle = setInterval(refresh, 1000);
}
panel.querySelector('#syncPlusSyncBtn').addEventListener('click', function () {
var t = token();
if (!t) { result.textContent = 'Not logged in.'; return; }
result.textContent = 'Syncing...';
fetch('/SyncPlus/Stats/Sync', { method: 'POST', headers: { 'X-Emby-Token': t } })
.then(function (r) { return r.json(); })
.then(function (d) { result.textContent = d.Message || 'Done.'; })
.catch(function (e) { result.textContent = 'Error: ' + e.message; });
});
// The cog menu is an action sheet built fresh on every open, so watch for it appearing
// shortly after a click on the OSD settings button and append a single item. Just one
// item (both stats + the sync button live in our own panel it opens) keeps the sheet
// from growing tall enough to overflow off-screen -- jellyfin measures and positions
// the sheet before our async injection runs, so every added item risks pushing content
// past the viewport bottom. The sheet's own delegated click handler closes it on any
// .actionSheetMenuItem click, so our item gets native close behavior for free.
var lastCogClick = 0;
document.addEventListener('click', function (e) {
if (e.target && e.target.closest && e.target.closest('.btnVideoOsdSettings')) {
lastCogClick = Date.now();
}
}, true);
function makeItem(id, text, handler) {
var btn = document.createElement('button');
btn.setAttribute('is', 'emby-button');
btn.setAttribute('type', 'button');
btn.setAttribute('data-id', id);
btn.className = 'listItem listItem-button actionSheetMenuItem';
btn.innerHTML = '<div class="listItemBody actionsheetListItemBody"><div class="listItemBodyText actionSheetItemText">' + text + '</div></div>';
btn.addEventListener('click', handler);
return btn;
}
new MutationObserver(function (mutations) {
if (Date.now() - lastCogClick > 1500) { return; }
for (var i = 0; i < mutations.length; i++) {
var added = mutations[i].addedNodes;
for (var j = 0; j < added.length; j++) {
var node = added[j];
if (!(node instanceof HTMLElement)) { continue; }
var sheet = node.classList && node.classList.contains('actionSheet') ? node : node.querySelector && node.querySelector('.actionSheet');
if (!sheet || sheet.querySelector('[data-id="syncplus-stats"]')) { continue; }
var scroller = sheet.querySelector('.actionSheetScroller');
if (!scroller) { continue; }
scroller.appendChild(makeItem('syncplus-stats', 'SyncPlay stats', togglePanel));
}
}
}).observe(document.body, { childList: true, subtree: true });
// Close the panel when playback ends -- the cog (and the group context) is gone.
setInterval(function () {
if (!document.querySelector('video') && panel.style.display !== 'none') {
closePanel();
}
}, 1000);
})();
""";
/// <summary>
/// Result of <see cref="GetMyStats"/>.
/// </summary>
public class MyGroupStatsDto
{
/// <summary>
/// Gets or sets the caller's group stats, or null when the caller isn't in a
/// SyncPlay group.
/// </summary>
public GroupStatsDto? Group { get; set; }
}
/// <summary> /// <summary>
/// Result of a <see cref="SyncMe"/> call. /// Result of a <see cref="SyncMe"/> call.
/// </summary> /// </summary>