Skip to content

Live updates

The server tells open pages what changed, so they don't have to keep asking. GET /v1/events is a Server-Sent Events stream (PR #46). Commands still go through the REST API; the stream only goes from the server to the page.

  • The bus (app/services/events.py) is in memory, which works because Nessie is always one uvicorn process. bus.publish(kind, data, audience) can be called from any thread (scans and mpv's events have their own), and hands the event to each connection's event loop. audience is EVERYONE, ADMINS, or a set of profile ids; each stream gets only what its Listener may hear. Nothing is kept: a page that wasn't connected asks again when it connects.
  • Events are coarse. They say what changed, and the page asks the API for what it is now, as itself, so nothing reaches a person that the API wouldn't show them. The events:
  • ready: sent first, and after every reconnect. Ask for what you show.
  • library {library_id}: sent when a scan finishes (so after a Lidarr webhook's rescan too) and when a library is added. main.js fires nessie:libraries-changed, which reloads the sidebar's counts.
  • scan: the scan's status (ScanStatusOut), the one event that carries data. It's sent at most every 0.5 s while scanning (Throttled), and always when the scan is queued, starts or ends.
  • player: the server's player changed, after any command, mpv's own moves on (Player(on_change=): the next song, the queue's end, an error) and AirPlay changes (AirPlayMonitor(on_change=)).
  • notice {text, variant, kind}: a notification, written by the server and shown as an 8-second toast (main.js, toast(text, variant, { delay })). See Notifications below.
  • likes: this person's liked songs changed (PR #56), sent to them (and admins, who just ask for their own). likes.js asks GET /v1/likes again.
  • resync: a slow connection lost events (MOST_WAITING). Treated like ready.A comment every 20 s keeps proxies from closing a quiet stream.
  • The page (frontend/js/live.js): startLiveUpdates() opens one EventSource after sign-in. live fires ready, down, and each event's kind; isLive() and <html data-live> say whether it's connected. The browser reconnects by itself. When the answer wasn't a stream at all (an older server, a sign-in page), it tries again every 15 s, and the page polls as before in the meantime.
  • ServerOutput, while live, asks on each player event, one request at a time, and moves the time played on by itself every second (tick(), for AirPlay's time too). It checks every 30 s in case something was missed. When not live, it polls every second as before.
  • nessie-scan-progress, while live, takes scan events for its library, and asks once on ready. When not live, it polls every second.

Notifications

app/services/notices.py sends notice events (PR #48). Since PR #51 each is also kept for the bell (see below), and the event carries its id and created_at. Each one is logged (INFO: app.services.notices: Notice for admins: …). app/main.py turns on INFO for the app logger, which was silent before. A notice that arrives while the page is hidden waits until the page is visible again (main.js).

  • Scans: ScanJob.announce is true for scans someone started (Settings → Libraries, adding a library) and false for the Lidarr webhook's rescan(). Announced scans tell admins "Scanning Music…" and "Scan of Music finished: 3 added." (or failed). library_routes._scan_changed calls notices.scan_changed(job, name) on queued, done and failed.
  • Waiting for a scan: notify_after_scan(library_id, notice, audience) holds a notice until that library's next scan is over, and not while job.again says it runs once more. An import's "was added to Music" uses it, so the album is there when the toast shows. The webhook registers it before queuing the rescan, so a quick scan can't finish first.
  • Lidarr requests: lidarr_requests (mbid, user_id, requested_at) records who requested each album (notices.requested(), from POST …/lidarr/albums). A non-admin's request also tells admins "Alex requested “Kid A” from Lidarr." Rows go when the album is imported, or after 90 days (REQUEST_KEPT).
  • Lidarr's webhook (notices.lidarr_event()): Grab (albums[].mbId) gives "Downloading …" and is remembered by its downloadId (the last 200), so DownloadFailure, which names only the release, can say which album failed and tell its requesters. Download (album.mbId, not upgrades) gives "… was added to Music." after the scan, ImportFailure gives a warning, and Test gives "Lidarr's webhook is connected." Lidarr's mbId is the release group's MusicBrainz id, the same id Nessie requests with. The audience is the album's requesters (a frozenset of profile ids, which admins also hear), or just admins.

The bell

notify() keeps each notice before sending it (keep()): a row in notifications (text, variant, kind, audience: everyone, admins or profiles) and, for profiles, rows in notification_recipients. Rows older than 30 days (KEPT) go at the next one. If storing fails the toast is still sent. It opens its own session through _session(), which reads db_session.SessionLocal when called, so tests can point it at theirs.

  • Listing: shown_to(session, listener) applies Listener.hears in SQL (admins all; others everyone or their recipient rows), leaves out what they cleared, and returns the latest 50 (MOST_SHOWN) with seen and unread.
  • Read and cleared are two numbers per profile: users.notifications_seen and notifications_cleared, the newest id. They never go back, and are clamped to the newest that exists. clear() also marks seen. Rows are shared, so clearing is only ever a filter for one person. With profiles off there's no user row: the API answers seen: null and the page keeps both in localStorage.
  • The page: <nessie-notifications> (see Components).

Watch for this

Lidarr scans the library through On Retag as well as On Release Import, so the sidebar can update while "… was added" never shows. That means On Release Import isn't ticked on the webhook: ask Lidarr (GET /api/v1/notification) which events it sends.

Watch for this

An open stream keeps uvicorn from stopping: it waits for open connections before shutting down. The Dockerfile, the dev compose file and the e2e server therefore pass --timeout-graceful-shutdown (3 s; 1 in tests). Stopping then logs Cancel 1 running task(s), which is expected. bus.close() in the lifespan ends streams too, but it runs after the wait. Over plain http://, a browser allows 6 connections to a server, and each open tab's stream holds one. Through HTTP/2 (Caddy) the limit doesn't apply.

Watch for this

Testing streams: Starlette's TestClient waits for a whole response, which a stream never finishes, so tests/test_live_updates.py calls the route function and reads its body. Its async tests run on their own thread, because Playwright leaves an event loop on the main one. In Playwright tests, wait with page.wait_for_timeout(), not time.sleep(): sleeping holds back the page's requests that are waiting on your page.route handlers.