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>
This commit is contained in:
2026-07-10 13:55:51 +02:00
co-authored by Claude Fable 5
parent 8dadef4184
commit 268a6ed2d0
4 changed files with 383 additions and 61 deletions
+42
View File
@@ -361,6 +361,48 @@ modes the reverted features did.
real drifted second device (only one browser session available this
round, same recurring constraint as everything else that needs two real
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)