No description
  • C# 88.3%
  • HTML 6.7%
  • JavaScript 3.2%
  • Python 1.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-08-03 19:30:10 +00:00
.forgejo/workflows feat: publish as a Jellyfin plugin repository 2026-08-03 18:14:22 +02:00
scripts feat: publish as a Jellyfin plugin repository 2026-08-03 18:14:22 +02:00
src/Jellyfin.Plugin.JellySync fix: scope video stream keys by item, not just encode configuration 2026-08-03 21:28:55 +02:00
.gitignore feat: shared transcoding for SyncPlay groups 2026-08-03 17:53:35 +02:00
build.yaml fix: scope video stream keys by item, not just encode configuration 2026-08-03 21:28:55 +02:00
Directory.Build.props feat: shared transcoding for SyncPlay groups 2026-08-03 17:53:35 +02:00
Jellyfin.Plugin.JellySync.sln feat: shared transcoding for SyncPlay groups 2026-08-03 17:53:35 +02:00
manifest.json chore(release): add 1.0.10.0 to plugin manifest 2026-08-03 19:30:10 +00:00
README.md feat: publish as a Jellyfin plugin repository 2026-08-03 18:14:22 +02:00

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.Controller 10.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.
  • CODECS in 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.