Omahub
← All plugins
K

Jellyfin Now Playing

by Kyrunner

What is playing on Jellyfin, with poster artwork, transport controls, and whether the server is transcoding.

Install
$ omarchy plugin add https://github.com/Kyrunner/jellyfin-nowplaying --enable
Widgets #bar #quickshell #media

Jellyfin Now Playing — Omarchy bar widget

Who is watching Jellyfin, and whether the server is transcoding for them. Read-only: it never controls playback.

Quiet when the server is reachable and nobody is watching. A fault stays visible and worded, because a hidden widget and a broken widget must not look the same.

"Not yet" is not a fault. The bar starts about ten seconds before WiFi associates, so the first poll of every boot fails. While a poll is failing the widget retries every 5s and says nothing; it reports a fault only after 45s of continuous failure, and one success resets that clock. So a boot is quiet, a network blip is quiet, and a genuinely dead server still speaks up within a minute.

Configure

~/.config/omarchy-jellyfin/config.json, mode 600:

{ "url": "http://<jellyfin-host>:8095", "token": "<jellyfin api key>" }

Use a dedicated Jellyfin API key (Dashboard → API Keys). Jellyfin keys are unscoped, so a separate key means revoking this machine's access never breaks anything else.

Install on a new machine

Requires Omarchy 4 ("Quattro", Quickshell bar). These steps are what a working install actually needs — each trap below cost a debugging round on the first one.

# 1. Drop the plugin in place (dir name MUST be the manifest id)
mkdir -p ~/.config/omarchy/plugins
cp -r jellyfin-nowplaying ~/.config/omarchy/plugins/ky.jellyfin-nowplaying

# 2. Config — mode 600, outside the plugin dir so the token is never in the repo
mkdir -p ~/.config/omarchy-jellyfin
cat > ~/.config/omarchy-jellyfin/config.json <<'EOF'
{ "url": "http://<jellyfin-host>:8095",
  "token": "<dedicated jellyfin api key>",
  "web_base": "https://<public-jellyfin-url>" }
EOF
chmod 600 ~/.config/omarchy-jellyfin/config.json

# 3. Prove the data layer BEFORE touching the bar — this is why backend.sh exists
bash ~/.config/omarchy/plugins/ky.jellyfin-nowplaying/backend.sh

# 4. Register it in the bar, then restart the shell
#    Add {"id": "ky.jellyfin-nowplaying"} to bar.layout.right in ~/.config/omarchy/shell.json
omarchy-restart-shell
  • web_base is optional but drives the click-through; without it the title links to url (the LAN address), which won't resolve off-network.
  • A bar widget goes in bar.layout.right, NOT the top-level plugins[] array. That array is for overlay plugins (cliamp, wallpaper-engine); a bar widget listed there silently never appears. Put the entry in bar.layout.right alongside your other bar items, e.g. between omarchy.tray and a VPN widget.
  • omarchy-restart-shell, not omarchy-refresh-shell. Refresh only re-reads config and leaves the running Quickshell process untouched, so new plugin QML is never loaded.
  • ⚠️ If the widget is invisible with nothing in the logs, the Panel is missing implicitWidth/implicitHeight. The bar allocates zero width and logs no error — the failure is silent and looks identical to "plugin not loaded." Panel.qml sets both deliberately; the zero-width case is also how it hides when nobody is watching.
  • Icon: nowplaying.png is generated by make_icon.py (PIL, 8× supersample). Only re-run it to change the artwork.

Test the backend without the widget

This is the point of keeping the fetch in a script — it can be proven from a terminal:

bash backend.sh                                  # uses the real config
OMARCHY_JELLYFIN_CONFIG=/tmp/other.json bash backend.sh
printf '%s' "$(cat fixture.json)" | python3 flatten.py   # transform alone, against a saved response

Expected shapes:

{"ok":true,"error":null,"streams":[…]}          # streams: [] when nobody is watching
{"ok":false,"error":"not configured", …}        # exit 1 — no config file
{"ok":false,"error":"bad config", …}            # exit 1 — file unreadable, or url/token missing
{"ok":false,"error":"auth failed", …}           # exit 1 — the server rejected the token
{"ok":false,"error":"unreachable", …}           # exit 1

bad config and auth failed are kept strictly apart. A config mistake reported as an auth failure sends you to Dashboard → API Keys, where everything looks correct, while the real fault goes unnamed.

The two suites run with no server and no credentials:

node Readiness.test.js          # the grace-window decision table
bash backend-config.test.sh     # config parsing, against a stub Jellyfin on localhost

Requirements

Omarchy 4 ("Quattro", Quickshell bar), plus curl, python3 and sed — all present on a stock Omarchy install. Nothing else is pulled in, and the plugin writes nothing outside its own popout.

Remove

# 1. Take it out of the bar: delete the {"id": "ky.jellyfin-nowplaying"} entry
#    from bar.layout.right in ~/.config/omarchy/shell.json
# 2. Drop the plugin
rm -rf ~/.config/omarchy/plugins/ky.jellyfin-nowplaying
# 3. Drop the config (this is the only file the plugin creates outside its own directory)
rm -rf ~/.config/omarchy-jellyfin
# 4. Reload the bar
omarchy-restart-shell

Then revoke the Jellyfin API key in Dashboard → API Keys. Using a dedicated key is what makes that a one-click action with no side effects.

Files

File Role
manifest.json Identity, bar-widget kind, refreshIntervalSec setting
backend.sh Config + curl + HTTP status handling. The only thing that talks to Jellyfin
flatten.py Turns /Sessions into one flat line. Separate file on purpose — a bash heredoc and piped stdin cannot coexist, and it makes the transform testable against a fixture
JellyfinService.qml Poll cadence, Process, parsed state. No layout
Panel.qml Bar button + popout. Presentation only
Readiness.qml / .js When to poll, and when a failure is old enough to be worth showing. The logic is a pure function so it can be tested without a bar or a reboot
Readiness.test.js node Readiness.test.js — the grace-window decision table
control.py One command to one session. Separate route for transport vs general commands
backend-config.test.sh bash backend-config.test.sh — config parsing against a stub server
jellyfin.png / .svg Bar icon and popup header mark

Preview image

preview.png shows Night of the Living Dead (1968), which is in the public domain in the US — its original release prints omitted the copyright notice, so neither the film nor its poster art is under copyright. The poster is the 1968 one-sheet from Wikimedia Commons, also public domain.

Everything identifying is a placeholder: demo for the viewer, Living Room for the device, and a generic 1080p - H264 - SDR in place of the real stream label — that line otherwise carries the release-group tag from the actual file, which has no business in a published screenshot. No real library metadata is reproduced anywhere in this repository.

Credits and trademarks

Icon from dashboard-icons (jellyfin.svg, jellyfin.png), licensed Apache-2.0. It replaced a hand-drawn jellyfish-with-play-triangle: Jellyfin's real mark is a rounded triangle, not a jellyfish, which also makes it harder to confuse with the Seerr orb sitting beside it in the same bar. "Playing" is carried by the widget being visible at all — it hides when idle — so the icon does not need to say it a second time.

The Jellyfin name and logo are trademarks of the Jellyfin project, used here for identification only. This plugin is not affiliated with or endorsed by it. Plugin code is MIT — see LICENSE.

Transport controls

Each stream gets its own row: previous, −30s, play/pause, +30s, next, stop, and mute/volume down/up.

Controls target the session, not the item. Jellyfin routes commands to the client that is playing, so two devices watching the same film are two independent remotes — which is why the buttons sit per-stream rather than once at the bottom.

  • controllable requires BOTH SupportsRemoteControl and SupportsMediaControl. They cover different routes (/Command and /Playing respectively) and a client can advertise one without the other. When false the buttons grey out and the panel names the client that cannot be driven, rather than leaving a dead row to be guessed at.
  • Seek is absolute-only on Jellyfin, so control.py reads a fresh PositionTicks and computes the target itself instead of trusting the panel's number, which is up to one poll interval stale. It clamps to just short of the runtime: seeking past the end makes a client stop dead rather than advance.
  • Transport and general commands are different routes. POST /Sessions/<id>/Playing/<verb> for PlayPause/Stop/NextTrack/PreviousTrack/Seek, POST /Sessions/<id>/Command/<verb> for VolumeUp/VolumeDown/ToggleMute. Sending one to the other's path 404s.
  • Commands never report their own result. The next poll is the authority — a pause the client refused must not render as paused just because it was asked for. The confirming poll is deferred 400ms, because the client ACKs through Jellyfin before it has applied anything.

Verified against a Kodi client on 2026-08-13: play/pause both directions, ±30s seek accurate to the second, and volume audible through HDMI-CEC to an AV receiver.

./backend.sh control <session_id> playpause   # next previous stop back30 fwd30 volup voldown mute

Poster artwork

Each stream shows a poster: the series poster for an episode, the item's own Primary for a film. A series poster is what "poster artwork" means for TV — tall, recognisable, identical across a season — whereas an episode's own Primary is a still frame from that episode, often a dark or ambiguous one. Content with no artwork simply shows no thumbnail.

Two decisions worth keeping:

  • No credential is attached to the image URL. Jellyfin serves /Items/<id>/Images/* unauthenticated, which is what makes it safe to hand the URL straight to QML's Image. Appending api_key= would work too, and would put a live token into QML and into anything that logs it.
  • Posters load over the LAN url, not web_base. backend.sh passes both: web_base builds the click-through link that must work from anywhere, url builds the poster link. Otherwise every poll would drag artwork through the public edge to display it at home.

maxHeight=180 makes Jellyfin serve a ~20KB scaled copy instead of the full-size original, and it caches the scaled version after the first request. The image tag is in the URL so a changed poster busts the cache.

Editing Panel.qml requires omarchy-restart-shell. Plugin hot-reload picks up backend.sh and the Python helpers, but keeps serving a cached compile of the QML — the edit appears to do nothing, and errors report against line numbers from the old file.

Notes

  • TranscodingInfo only appears while Jellyfin is actually transcoding, so its presence is the transcode signal — no inferring from codec names. On direct play the bitrate is read from the video MediaStream instead.
  • Do not gate the poster's visible on status === Image.Ready. An Image that starts invisible at zero layout width is never driven to load, so the status it waits for never arrives and the poster silently never appears — with nothing in the logs.
  • Colours come from Omarchy's own theme objects, so it follows theme switches with nothing to maintain.
  • A failed poll keeps the last known streams and marks them stale rather than blanking.
  • The 45s grace window is measured as real elapsed time, accumulated one poll at a time and sanity-checked against the delay actually scheduled. It has to be: systemd-timesyncd makes its first correction inside that very window, and a suspend moves the clock by hours.