Jellyfin plugin: a Share button that makes links unfurl with the poster, title, description and tags (OpenGraph).
  • C# 64.6%
  • JavaScript 23.3%
  • Python 12.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-01 00:50:38 +02:00
Api feat: share button with OpenGraph link previews 2026-09-30 23:26:03 +02:00
Configuration feat: share button with OpenGraph link previews 2026-09-30 23:26:03 +02:00
tools feat: share button with OpenGraph link previews 2026-09-30 23:26:03 +02:00
Web feat: share button with OpenGraph link previews 2026-09-30 23:26:03 +02:00
.gitignore feat: share button with OpenGraph link previews 2026-09-30 23:26:03 +02:00
Jellyfin.Plugin.ShareButton.csproj feat: share button with OpenGraph link previews 2026-09-30 23:26:03 +02:00
LICENSE feat: share button with OpenGraph link previews 2026-09-30 23:26:03 +02:00
Plugin.cs feat: share button with OpenGraph link previews 2026-09-30 23:26:03 +02:00
PluginServiceRegistrator.cs feat: share button with OpenGraph link previews 2026-09-30 23:26:03 +02:00
README.md fix: reality 2026-10-01 00:50:38 +02:00
ScriptInjectionMiddleware.cs feat: share button with OpenGraph link previews 2026-09-30 23:26:03 +02:00
ScriptInjectionStartupFilter.cs feat: share button with OpenGraph link previews 2026-09-30 23:26:03 +02:00

jellyfin-opengraph

A Jellyfin plugin that adds a Share button to the item detail page and makes the link it produces unfurl properly in Discord, WhatsApp, Telegram, Slack, X and anything else that reads OpenGraph tags — showing the poster, title, description and tags.

🎉 I built nothing, Qwen did it for me!

Let's be clear about the division of labour here. I built nothing. Qwen did it for me — every last scrap of it — and I am thrilled about that.

I contributed the ideas, the opinions, and an unshakeable faith in the plan. Qwen contributed the code, the debugging, the reverse-engineering of a hash route that cannot possibly unfurl, and the discovery that a Jellyfin season has no poster of its own and quietly 404s every single link preview. Between those two efforts, I think we can all agree mine was the difficult one.

Special thanks also to the two bugs I shipped while "helping". 🙏

The problem it solves

Jellyfin's detail page is a hash route:

https://jellyfin.example.com/web/#/details?id=<itemId>

Everything after # is never sent to the server. A crawler fetching that URL only ever receives the empty SPA shell, so there is no metadata for it to read and link previews are impossible from that URL. No amount of client-side JavaScript fixes this, because unfurlers do not run JavaScript.

So the share button produces https://jellyfin.example.com/ShareButton/Item/<itemId> instead. That URL answers crawlers with real OpenGraph HTML, and sends humans on to the detail page:

requester response
crawler (Discordbot, WhatsApp, TelegramBot, …) 200 + OpenGraph HTML
anything else (a human's browser) 302 → /web/#/details?id=<itemId>

You get a working preview and a link that lands on the show.

What it looks like

The Share button is inserted directly before the "Mark as played" (btnPlaystate) button, in Jellyfin's own mainDetailButtons row. It uses navigator.share where the browser supports it (mobile, Safari), and otherwise copies the link to the clipboard and shows a toast.

No files are written into jellyfin-web. The script is embedded in the assembly and served from /ShareButton/share.js, so uninstalling leaves nothing behind to clean up by hand.

Install

Requires Jellyfin 12.1 (targetAbi: 12.1.0.0), built against Jellyfin.Controller 10.11.0.

# build
nix shell nixpkgs#dotnet-sdk_9 -c dotnet build -c Release

# install
PLUGIN_DIR=/var/lib/jellyfin/plugins/ShareButton_1.0.0.0
sudo mkdir -p "$PLUGIN_DIR"
sudo cp bin/Release/net9.0/Jellyfin.Plugin.ShareButton.dll "$PLUGIN_DIR/"
sudo chown -R jellyfin:jellyfin "$PLUGIN_DIR"
sudo systemctl restart jellyfin

meta.json sits next to the DLL and tells Jellyfin which ABI the plugin wants. It is runtime state rather than source (Jellyfin rewrites it), so it is not committed — write it alongside the DLL before restarting:

{
  "category": "General",
  "description": "Adds a Share button to the item detail page and an OpenGraph preview endpoint.",
  "guid": "141a6f60-df4d-489a-8494-d1202e7cd753",
  "name": "Share Button",
  "owner": "maikel",
  "targetAbi": "12.1.0.0",
  "version": "1.0.0.0",
  "status": "Active",
  "autoUpdate": false
}

Configuration

Written by Jellyfin to config/plugins/Share Button/config.xml:

option default meaning
PublicBaseUrl (empty) Public base URL used in share links and image URLs, e.g. https://jellyfin.example.com. Empty derives it from each request.
IncludeTags true Put the item's tags in the description and as article:tag.
AllowAnonymousPreviews true Allow the preview endpoint to be read without signing in. Turning this off disables previews everywhere — see below.
MaxTags 8 How many tags to include.

Behind a reverse proxy

PublicBaseUrl exists because a TLS-terminating proxy hands the backend a plain-HTTP connection, so Request.Scheme is http and every generated URL comes out insecure — which then fails mixed-content checks in the exact unfurlers this plugin exists for.

Rather than force you to set it, the plugin honours X-Forwarded-Proto and X-Forwarded-Host first, and only falls back to the request's own Host. Set PublicBaseUrl if your proxy rewrites Host to the backend address, because then the derived URL is useless to anyone outside.

Why the preview endpoint is anonymous

It has to be. Discord, WhatsApp and Telegram fetch the URL as an unauthenticated crawler; if the endpoint requires a session, they get a login page and the unfurl shows nothing.

The trade-off is real and worth stating plainly: anyone holding a link can read that one item's title, description, tags and poster. Item ids are not enumerable in practice (32 hex chars), but a leaked link is readable. AllowAnonymousPreviews: false locks it down and, by the same token, switches off previews everywhere.

Endpoints

route auth purpose
GET /ShareButton/share.js anonymous The injected client script.
GET /ShareButton/Item/{id} anonymous OpenGraph HTML for crawlers, 302 to the detail page otherwise.
GET /ShareButton/Item/{id}/info authenticated Item summary as JSON, for the share sheet.

Design notes

og:image points straight at Jellyfin. /Items/{id}/Images/Primary returns 200 anonymously, so there is no poster proxy to build or cache.

The card describes whoever owns the image. A Jellyfin Season has no poster of its own — ImageTags is empty and the UI draws the series image for it — so /Items/<seasonId>/Images/Primary is a hard 404. Sharing from a season page (whose URL is #/details?id=<seasonId>) therefore used to produce a card with no image at all: no error, no log, the crawler simply dropped it. ResolvePreview() now keeps the item when it owns a primary image, and otherwise walks up (Season → Series, Episode → Season → Series) to whatever actually holds the picture. The card's title, plot, tags and link all follow the same resolution, so a season link shows and opens the show.

Whitespace is flattened. Plot text is full of newlines; a raw newline inside content="…" splits the attribute and a strict parser sees nothing.

Housekeeping tags are filtered. Every item carries Hardsub, goodsubs, 9:16, 30 Episodes; left in, they crowd out the genre tags the preview is meant to show.

og:image:width/height are omitted. The request scales the image via maxHeight, so the stored dimensions are not what a crawler downloads, and a wrong aspect hint is worse than none.

twitter:card follows the image shape. Posters are portrait (summary), episode stills are landscape (summary_large_image); the two card types want opposite shapes.

Tests

# client: pulls the real basePath() out of Web/share.js and runs it
node tools/test-basepath.js

# server: every item's advertised og:image must actually load (needs an API key)
python3 tools/check-previews.py --token <jellyfin-api-key>

check-previews.py exists because of the season bug above. The gate is not "the endpoint returned 200" — it always did — but "the image URL it advertises resolves". It sweeps every movie, series and season plus a sample of episodes and exits non-zero if any image fails.

The basePath() regression test covers a different silent failure: script.src is always absolute, so a naive regex captured https://host as the "base path", and every share link came out as https://hosthttps://host/ShareButton/Item/<id>.

Compatibility

Built and verified against Jellyfin 12.1.0. The targetAbi is 12.1.0.0 while the package references are Jellyfin.Controller/Jellyfin.Model 10.11.0 — that pairing is what this server reports and what the plugin loads against.

License

MIT — see LICENSE.