# ky.seerr-requests — design Bar widget that surfaces Seerr requests awaiting approval, notifies when a new one arrives, and approves or declines them in place. Verified against Seerr **3.4.1** on 2026-08-11. Seerr is the project formed by merging Overseerr and Jellyseerr; the API paths below are unchanged from both. ## Why a bar widget and not a service The request is "tell me when something needs approval." Approval is an *action*, so a notification with no surface to act on just starts a hunt for a browser tab. A `bar-widget` is mounted at shell startup and runs its timer continuously — the same way `ky.jellyfin-nowplaying` polls every 10s — so it fires the notification *and* owns a place to act. A `service` plugin would add nothing and remove the UI. ## Structure Mirrors `ky.jellyfin-nowplaying`: one shell script owns all Seerr I/O, the QML renders whatever the script prints. The script can be run and diffed over SSH; the widget can only be checked by eye on the owner's screen. ``` manifest.json bar-widget declaration + settings schema backend.sh the ONLY thing that talks to Seerr Panel.qml bar item + popup README.md setup and behavior ``` ## Config `~/.config/omarchy-seerr/config.json`, overridable via `$OMARCHY_SEERR_CONFIG`: ```json { "url": "http://192.168.1.10:5055", "api_key": "...", "web_base": "https://seerr.example.com", "public_url": "https://seerr.example.com" } ``` `url` is the LAN API path used for polling. `web_base` is only used to build browser links, so a click lands somewhere reachable away from home instead of a LAN address. Falls back to `url` when unset — same split as the Jellyfin plugin. `public_url` is the API fallback, same design as the NZBGet and Navidrome plugins: LAN first with a short timeout, public only on failure, the choice remembered in `~/.local/state/omarchy-seerr/endpoint.json` for 10 minutes so a poll away from home does not pay the LAN timeout every minute. It defaults to `web_base` because the public web UI is the same Seerr and serves the API too. Approve/decline moved from curl in `backend.sh` into `poll.py` so an action takes the same road as the poll that showed the request. Auth failures never fail over — retrying a bad key against the public edge is how you get banned by your own rate limiter. Auth is the `X-Api-Key` header, not Jellyfin's `MediaBrowser Token=` scheme. `backend.sh` reads the three fields one per line and guards each on its own. That is deliberate, and the reason is a bug this plugin shipped: a space-separated `read -r URL API_KEY WEB_BASE` collapses whitespace runs, so a config with no `api_key` shifted `web_base` into its place, passed the non-empty check, and sent a URL as the credential. The server answered 401 and the widget said **auth failed** — pointing at a credential problem that did not exist while the real fault, a malformed config, went unnamed. Misreporting a fault is worse than reporting none: it sends the reader somewhere there is nothing to find. `bash backend-config.test.sh` covers the parse table against a stub server, including proving that the key on the wire is the key in the file. Every Text element in every `.qml` file sets `textFormat: Text.PlainText`: titles and requester names come from Seerr, and Qt's default AutoText would read a markup-looking string as rich text. `bash plain-text.test.sh` enforces it. ## Polling: two tiers `GET /api/v1/request/count` returns counts only: ```json {"total":141,"pending":2,"approved":48,"declined":1,...} ``` That is the whole badge. It runs on the interval timer (default 60s) and is one small request. The expensive path — the full pending list plus per-title resolution — runs only when `pending > 0` or the popup is open. ### The N+1, and why it is bounded A Seerr request object carries `media.tmdbId` but **no title**: ```json {"id":42,"status":1,"type":"movie","createdAt":"2026-08-09T13:38:48.000Z", "media":{"tmdbId":10331,"mediaType":"movie"},"requestedBy":{"displayName":"user"}} ``` Titles and posters need a follow-up `GET /api/v1/movie/{tmdbId}` or `/api/v1/tv/{tmdbId}` each, returning `title`/`name`, `releaseDate`, `posterPath`. That is one extra call per *pending* item, not per request in the database — 2 today, and a queue large enough to matter is a queue you would have already cleared. `backend.sh` caches resolved titles by tmdbId in `~/.local/state/omarchy-seerr/titles.json`, so a request sitting pending for a day is resolved once rather than 1,440 times. Cache entries are immutable (a film's title and year do not change) and keyed `:`. ## Notifications New pending request IDs fire one `notify-send`: > **Seerr request** — user requested *Night of the Living Dead* (1968) Seen IDs persist to `~/.local/state/omarchy-seerr/seen.json`. Without that, every shell restart re-toasts every pending request — the behavior that gets a plugin uninstalled. IDs are pruned from `seen.json` once they leave the pending set, so the file tracks the queue rather than growing without bound. First run after install seeds `seen.json` silently instead of toasting the existing backlog. ## Bar states | State | Bar shows | |-------|-----------| | 0 pending | hidden (configurable: dimmed logo, no badge) | | n pending | logo + accent-coloured badge with the count, `99+` above 99 | | failing < 45s, nothing known yet | hidden — the boot case, before the first poll lands | | failing < 45s, queue known | unchanged: last known count, unmarked — only the hover tooltip flags it as stale | | not configured | dimmed logo + red `!` badge, popup explains where the config goes | | unreachable / auth failed for 45s | dimmed logo + red `!` badge, popup names which | Failure must not render as "no requests." A dead API key looking identical to a quiet queue is the failure mode that matters here — it fails silently and stays failed. So a fault changes *both* the colour and the glyph, and keeps its width in the bar rather than collapsing to zero the way an empty queue does. ## The grace window The bar starts about ten seconds before WiFi associates, so the first poll of every boot fails. Rendering that is a lie with a red badge on it. `Readiness.qml` (logic in `Readiness.js`, tested with `node Readiness.test.js`) holds the rule, and it is uniform — there is no startup special case. While a poll is failing it retries every **5s** instead of the configured interval, and reports a **fault only after 45s of continuous failure**. One success anywhere in that window resets the clock. So a boot is quiet, a blip is quiet, and a genuinely dead server still speaks up within a minute. The 45s is measured as real elapsed time, accumulated one poll at a time with each interval sanity-checked against the delay that was actually scheduled. It has to be: `systemd-timesyncd` makes its first correction inside this very window, and a suspend moves the clock by hours. A step forward is credited as one scheduled interval rather than an hour, so it cannot manufacture a fault; a step backward is credited the same way, so it cannot hide one. A widget that already holds data keeps showing it through a failing poll, unmarked — only the hover tooltip flags it as stale until 45s makes it a fault, at which point the popup says so explicitly. Only a widget with nothing to show hides while undecided — which is the boot case, and never a mid-session one. ### Why a badge and not text beside the icon `BarIconButton` hides its glyph `Text` whenever `iconComponent` is non-null (`BarIconButton.qml:32`), and the button is a fixed square slot (`fixedWidth: slotSize`). There is no room beside the logo, so the count rides on top of it. ### Theme colours The `bar` object handed to a widget carries `foreground`, `urgent` and `fontFamily`, but **not** `background` or `accent`. Reading those off it yields `undefined`, which QML assigns as black and reports only as a runtime warning — a widget that looks broken with nothing in the log to explain it. Both come from the `Color` singleton instead. ## Approve / decline `backend.sh approve ` and `backend.sh decline ` POST to `/api/v1/request//approve|decline`, then the panel re-polls. The row shows a spinner during the call and surfaces the error inline on failure. It does not optimistically remove the row — an approve that silently failed is worse than a slow one, because the requester is left waiting on a queue you believe you cleared. ## Settings schema | Key | Type | Default | Range | |-----|------|---------|-------| | `refreshIntervalSec` | integer | 60 | 15–600 | | `notifyOnNew` | boolean | true | | | `hideWhenEmpty` | boolean | true | | ## Out of scope - Browsing non-pending requests — that is what the Seerr web UI is for. - Issues, users, and settings surfaces. - Creating requests. This approves what others ask for; it is not a discovery UI.