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.audienceisEVERYONE,ADMINS, or a set of profile ids; each stream gets only what itsListenermay 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.jsfiresnessie: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.jsasksGET /v1/likesagain.resync: a slow connection lost events (MOST_WAITING). Treated likeready.A comment every 20 s keeps proxies from closing a quiet stream.- The page (
frontend/js/live.js):startLiveUpdates()opens oneEventSourceafter sign-in.livefiresready,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 eachplayerevent, 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, takesscanevents for its library, and asks once onready. 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.announceis true for scans someone started (Settings → Libraries, adding a library) and false for the Lidarr webhook'srescan(). Announced scans tell admins "Scanning Music…" and "Scan of Music finished: 3 added." (or failed).library_routes._scan_changedcallsnotices.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 whilejob.againsays 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(), fromPOST …/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 itsdownloadId(the last 200), soDownloadFailure, 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,ImportFailuregives a warning, andTestgives "Lidarr's webhook is connected." Lidarr'smbIdis the release group's MusicBrainz id, the same id Nessie requests with. The audience is the album's requesters (afrozensetof 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)appliesListener.hearsin SQL (admins all; otherseveryoneor their recipient rows), leaves out what they cleared, and returns the latest 50 (MOST_SHOWN) withseenandunread. - Read and cleared are two numbers per profile:
users.notifications_seenandnotifications_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 answersseen: nulland 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.