- C# 88.3%
- HTML 6.7%
- JavaScript 3.2%
- Python 1.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .forgejo/workflows | ||
| scripts | ||
| src/Jellyfin.Plugin.JellySync | ||
| .gitignore | ||
| build.yaml | ||
| Directory.Build.props | ||
| Jellyfin.Plugin.JellySync.sln | ||
| manifest.json | ||
| README.md | ||
JellySync
One shared transcode per SyncPlay group.
Stock Jellyfin decides playback per session. Five friends watching one 4K remux together means five independent ffmpeg jobs, which a single Intel Arc A310 will not survive. JellySync makes the whole group consume one encode.
What it guarantees
- One video encode per group. Not per viewer.
- Extra encodes only when unavoidable. If no single codec target can serve everybody, the plugin splits the group — and the number of splits is the provable minimum, not a heuristic (exact minimum set cover, see below).
- Different audio languages cost nothing extra. Video and audio are encoded as separate HLS renditions and joined in the manifest, so switching language never touches the video encode.
- One synchronised bitrate. By default the lowest cap in the group, so nobody buffers and nobody desyncs.
How it works
Jellyfin derives the transcode output path from
MD5(mediaPath + userAgent + deviceId + playSessionId) and keys all job deduplication on that path.
Two viewers can therefore never share a job, because their user agents and session ids differ.
JellySync sidesteps this by owning the jobs itself: it builds StreamState objects with a
deterministic per-cluster identity and hands them to ITranscodeManager.StartFfMpeg, so the shared
encodes still get core's job lifecycle (throttling, ping keepalive, cleanup) for free.
Core's master playlist only emits EXT-X-MEDIA for subtitles, never for audio, so it cannot express
"one video, several audio tracks". JellySync generates its own manifest:
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="jsaud",NAME="English",LANGUAGE="eng",DEFAULT=YES,URI="audio/<key>.m3u8"
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="jsaud",NAME="Deutsch",LANGUAGE="ger",DEFAULT=NO,URI="audio/<key>.m3u8"
#EXT-X-STREAM-INF:BANDWIDTH=...,RESOLUTION=1920x1080,AUDIO="jsaud"
video.m3u8
The video variant carries -an, each audio variant carries -vn. Audio renditions are keyed
independently of the video cluster, so two clusters that both want German share one audio encode.
The grouping algorithm
Each viewer's DeviceProfile is folded into a flat support matrix (codec × container, with
profile / level / bit depth / resolution / bitrate ceilings resolved from TranscodingProfiles and
CodecProfiles, including ApplyConditions evaluated against the actual source stream).
Finding the fewest encodes is minimum set cover, which is NP-hard in general. Group sizes here are small, so up to 20 viewers the solver runs an exact bitmask dynamic program over subsets and the result is optimal, not approximate. Above that it degrades to greedy. Ties at equal cardinality are broken toward the preferred codec.
Once a cluster is chosen, its target is the intersection of its members' limits: minimum resolution, minimum bit depth, minimum level, the highest encoder profile everybody accepts, and the bitrate policy applied across the cluster.
Late joiners never disturb a running encode: they either fit an existing cluster or get a new one added alongside.
Subtitles
Subtitles are never burned in — that would force a per-viewer video encode and defeat the point.
The client patch rewrites any Encode delivery method to External, so jellyfin-web keeps rendering
subtitles client side with its own machinery, including ASS styling. Everyone in the group can run a
different subtitle track at no GPU cost.
Requirements
- Jellyfin 10.11.x (built against
Jellyfin.Controller10.11.11, target ABI 10.11.0.0) - File Transformation — used to inject the client patch. Without it JellySync stays inactive.
- Browser clients (jellyfin-web). Native apps cannot be patched and fall back to normal per-user transcoding; they still play, they just do not share.
Installation
Install File Transformation first and restart, otherwise JellySync loads but stays inert.
Then add this repository under Dashboard → Plugins → Repositories:
https://git.imhof.cloud/julian/jellysync/raw/branch/main/manifest.json
JellySync then appears in the catalogue under General. Install it and restart.
Dashboard → Plugins → JellySync to configure. That page also shows a live view of how many viewers are mapping onto how many encodes.
The manifest is regenerated by CI on every tag, carrying the MD5 of the artifact actually published, so updates show up in the catalogue automatically.
Configuration
| Setting | Default | Notes |
|---|---|---|
| Grouping window | 2500 ms | How long to wait for the rest of the group before solving. |
| Bitrate policy | Lowest cap | Lowest / Average / Highest / Fixed. |
| Bitrate ceiling | off | Hard cap applied on top of the policy. |
| Codec preference | hevc,h264,av1 |
Only these are considered as shared targets, best first. |
| Segment length | 6 s | |
| Max encodes per group | unlimited | Hard ceiling on GPU load; viewers beyond it transcode privately. |
| Idle timeout | 60 s | How long a shared encode survives without pings. |
On an Arc A310, hevc first is usually right: QSV encodes it efficiently and it halves the bitrate
needed for a given quality compared to H.264.
Building
dotnet build src/Jellyfin.Plugin.JellySync/Jellyfin.Plugin.JellySync.csproj -c Release
Or without a local SDK:
docker run --rm -v "$PWD":/src -w /src mcr.microsoft.com/dotnet/sdk:9.0 \
dotnet build src/Jellyfin.Plugin.JellySync/Jellyfin.Plugin.JellySync.csproj -c Release
CI lives in .forgejo/workflows/build.yaml and runs on catthehacker/ubuntu:full-latest. Pushing a
v* tag builds, packages and publishes a release.
Limitations
- Movies and episodes only. Live TV and anything with a live stream id falls back to normal transcoding.
- Requires the group to actually be in SyncPlay. Watching the same file separately is not shared.
- A seek restarts the shared encode once for the whole group. Because SyncPlay seeks are group-wide this is the correct behaviour, but the restart is visible as a brief rebuffer for everyone.
CODECSin the master manifest is omitted rather than guessed when the target level is unknown.
Debugging
Set window.__jellySyncDebug = true in the browser console to see what the client patch is doing.
Server side, everything logs under JellySync — the solver logs one line per group showing how many
viewers mapped onto how many encodes and what each target is.