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_baseis optional but drives the click-through; without it the title links tourl(the LAN address), which won't resolve off-network.- ⛔ A bar widget goes in
bar.layout.right, NOT the top-levelplugins[]array. That array is for overlay plugins (cliamp,wallpaper-engine); a bar widget listed there silently never appears. Put the entry inbar.layout.rightalongside your other bar items, e.g. betweenomarchy.trayand a VPN widget. - ⛔
omarchy-restart-shell, notomarchy-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.qmlsets both deliberately; the zero-width case is also how it hides when nobody is watching. - Icon:
nowplaying.pngis generated bymake_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.
controllablerequires BOTHSupportsRemoteControlandSupportsMediaControl. They cover different routes (/Commandand/Playingrespectively) 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.pyreads a freshPositionTicksand 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'sImage. Appendingapi_key=would work too, and would put a live token into QML and into anything that logs it. - Posters load over the LAN
url, notweb_base.backend.shpasses both:web_basebuilds the click-through link that must work from anywhere,urlbuilds 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.qmlrequiresomarchy-restart-shell. Plugin hot-reload picks upbackend.shand 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
TranscodingInfoonly 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 videoMediaStreaminstead.- Do not gate the poster's
visibleonstatus === Image.Ready. AnImagethat 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-timesyncdmakes its first correction inside that very window, and a suspend moves the clock by hours.