feat: replace client polling with Server-Sent Events (SSE)
Some checks failed
Build and Push Docker Image / build (push) Successful in 23s
CI / Security audit (push) Successful in 38s
CI / Tests & coverage (push) Failing after 38s

Server:
- poller.js: add pollSubscribers Set with onPollComplete/offPollComplete;
  notify all SSE callbacks immediately after every successful poll
- dashboard.js: add GET /api/dashboard/stream endpoint (text/event-stream)
  - requireAuth enforced via cookie (no CSRF needed — GET is a safe method)
  - X-Accel-Buffering: no for nginx proxy compatibility
  - 25s heartbeat comments to survive proxy idle timeouts
  - initial payload sent immediately on connect
  - cleanup on req.close: deregister callback, stop heartbeat, remove client
  - active client tracking updated: type='sse', connectedAt, no refreshRateMs

Frontend:
- app.js: replace setInterval/fetchUserDownloads with EventSource
  - startSSE() opens /api/dashboard/stream; stopSSE() closes it
  - first incoming message hides loading spinner
  - showAll toggle re-opens stream with ?showAll=true param
  - logout calls stopSSE() before POST /api/auth/logout
  - status panel: fixed 5s refresh, shows SSE clients + connect duration
  - statusRefreshHandle now always 5s, not tied to old refresh-rate selector
- index.html: remove now-unused refresh-rate <select> element

Docs:
- ARCHITECTURE.md §4.3: update poller description
- ARCHITECTURE.md §5: rename to SSE Stream (§5.2) + Download Matching (§5.3)
- ARCHITECTURE.md §7: update active client tracking description
- ARCHITECTURE.md §9: add /stream endpoint, update /status clients schema
- ARCHITECTURE.md §10: update key functions table; replace Auto-Refresh
  section with Live Push via SSE
- class-server.puml: add /stream to dashboard routes; update ClientInfo
- component.puml: annotate dashboard with SSE note; update label
This commit is contained in:
2026-05-17 08:35:22 +01:00
parent 55e4aedfca
commit abdd0da306
7 changed files with 413 additions and 125 deletions

View File

@@ -221,7 +221,7 @@ sofarr/
**`cache.js`** — Singleton `MemoryCache` backed by a `Map`. Each entry has an expiration timestamp. Provides `get`, `set`, `invalidate`, `clear`, and `getStats` (returns per-key size, item count, TTL remaining).
**`poller.js`** — Background polling engine. Fetches data from all configured service instances in parallel with per-task timing. Stores results in the cache with a configurable TTL. Can be disabled entirely (`POLL_INTERVAL=0`), in which case data is fetched on-demand per dashboard request.
**`poller.js`** — Background polling engine. Fetches data from all configured service instances in parallel with per-task timing. Stores results in the cache with a configurable TTL. Can be disabled entirely (`POLL_INTERVAL=0`), in which case data is fetched on-demand. After each successful poll it notifies all registered SSE subscriber callbacks so connected clients receive data immediately.
**`qbittorrent.js`** — `QBittorrentClient` class with cookie-based authentication, automatic re-auth on 403, and persistent client instances. Includes torrent-to-download mapping (`mapTorrentToDownload`) and formatting utilities (`formatBytes`, `formatSpeed`, `formatEta`).
@@ -253,18 +253,31 @@ Every `POLL_INTERVAL` ms (default 5000), the poller fetches from all services in
Results are stored in the cache under `poll:*` keys with a TTL of `POLL_INTERVAL × 3`.
### 5.2 Dashboard Request
### 5.2 SSE Stream
When a user requests `/api/dashboard/user-downloads`:
When a browser opens `GET /api/dashboard/stream` (after authentication):
1. Read all `poll:*` keys from cache
2. Build `seriesMap` and `moviesMap` from embedded objects in queue records
3. Build `sonarrTagMap` and `radarrTagMap` from tag data
4. For each SABnzbd queue slot → try to match against Sonarr/Radarr queue records by title
5. For each SABnzbd history slot → try to match against Sonarr/Radarr history records
6. For each qBittorrent torrent → try to match against Sonarr/Radarr queue, then history
7. For each match, resolve the series/movie, extract the user tag, check if it belongs to the requesting user
8. Return only the user's downloads (or all, if admin with `showAll=true`)
1. Server sets `Content-Type: text/event-stream`, disables buffering (`X-Accel-Buffering: no`)
2. Immediately builds and sends the first payload (same matching logic as below)
3. Registers a callback with the poller's `onPollComplete` subscriber set
4. After every subsequent poll cycle completes the callback fires, rebuilds the payload, and writes a `data:` SSE frame
5. A 25-second heartbeat comment (`: heartbeat`) keeps the connection alive through proxies
6. On client disconnect: deregisters callback, stops heartbeat, removes from active-client map
The browser's native `EventSource` API handles reconnection automatically on network interruption.
### 5.3 Download Matching
For each connected user the server:
1. Reads all `poll:*` keys from cache
2. Builds `seriesMap` and `moviesMap` from embedded objects in queue records
3. Builds `sonarrTagMap` and `radarrTagMap` from tag data
4. For each SABnzbd queue slot → tries to match against Sonarr/Radarr queue records by title
5. For each SABnzbd history slot → tries to match against Sonarr/Radarr history records
6. For each qBittorrent torrent → tries to match against Sonarr/Radarr queue, then history
7. For each match, resolves the series/movie, extracts the user tag, checks if it belongs to the requesting user
8. Returns only the user's downloads (or all, if admin with `showAll=true`)
---
@@ -336,7 +349,7 @@ Users are matched to downloads via tags in Sonarr/Radarr:
### Active Client Tracking
Each dashboard request reports the client's refresh rate. The server tracks active clients in a `Map<username, { user, refreshRateMs, lastSeen }>`, pruning entries older than 30 seconds. This data powers the admin status panel's "effective refresh mode" display.
SSE connections are tracked precisely: registered on connect, removed on disconnect. The admin status panel shows each connected user and how long they have been connected. The `type: 'sse'` field distinguishes SSE clients from any legacy HTTP clients.
---
@@ -476,15 +489,38 @@ Clear session and revoke the Emby token server-side. Does **not** require a CSRF
---
### `GET /api/dashboard/stream`
Server-Sent Events stream. Opens a persistent connection that pushes download data on every poll cycle.
**Query Parameters:**
| Param | Type | Description |
|-------|------|-------------|
| `showAll` | `"true"` | (Admin) Include all users' downloads |
**Response:** `Content-Type: text/event-stream`
Each event is a `data:` frame containing JSON:
```json
{
"user": "Alice",
"isAdmin": false,
"downloads": [ /* download objects same shape as /user-downloads */ ]
}
```
The connection is kept alive with `: heartbeat` comments every 25 seconds. The browser's `EventSource` reconnects automatically on failure.
---
### `GET /api/dashboard/user-downloads`
Fetch downloads for the authenticated user.
Fetch downloads for the authenticated user (single HTTP request, no streaming).
**Query Parameters:**
| Param | Type | Description |
|-------|------|-------------|
| `showAll` | `"true"` | (Admin) Show all users' downloads |
| `refreshRate` | number (ms) | Client's current refresh rate for tracking |
**Response (200):**
```json
@@ -531,7 +567,7 @@ Admin-only server status.
]
},
"clients": [
{ "user": "Alice", "refreshRateMs": 5000, "lastSeen": 1715817600000 }
{ "user": "Alice", "type": "sse", "connectedAt": 1715817600000, "lastSeen": 1715817600000 }
]
}
```
@@ -577,13 +613,13 @@ The frontend is a **vanilla JavaScript SPA** with no build step. All logic resid
|----------|---------|
| `checkAuthentication()` | On load: check session → show dashboard or login |
| `handleLogin()` | Authenticate, fade login → splash → dashboard |
| `fetchUserDownloads()` | GET `/user-downloads`, update state, re-render |
| `startSSE()` | Open `EventSource` to `/stream`; handles incoming data + first-message loading hide |
| `stopSSE()` | Close `EventSource` and cancel reconnect timer |
| `renderDownloads()` | Diff-based card rendering (create/update/remove) |
| `createDownloadCard()` | Build DOM for a single download card; renders tag badges |
| `updateDownloadCard()` | Update existing card in-place (progress, speed, etc.) |
| `toggleStatusPanel()` | Show/hide admin status panel |
| `renderStatusPanel()` | Build status HTML (server, polling, cache, clients) |
| `startAutoRefresh()` | Start periodic `fetchUserDownloads` |
| `renderStatusPanel()` | Build status HTML (server, polling, SSE clients, cache) |
| `initThemeSwitcher()` | Light / Dark / Mono theme support |
### Themes
@@ -604,9 +640,11 @@ Download cards render tag badges in the card header:
- Tags with **no matching Emby user** → amber badge showing the raw tag label (leftmost)
- Tags **matched to a known Emby user** → accent badge showing the Emby display name (rightmost)
### Auto-Refresh
### Live Push via SSE
The dashboard polls the server at the user-selected interval (1s, 5s, 10s, or Off). The status panel auto-refreshes in sync. Client refresh rate is sent to the server on each request for active-client tracking.
The dashboard receives updates via a persistent `EventSource` connection to `GET /api/dashboard/stream`. The server pushes a new `data:` event immediately after every poll cycle completes — there is no client-side timer. The browser's `EventSource` implementation handles reconnection automatically on network interruption.
The status panel refreshes on a fixed 5-second timer and shows each SSE client with its connect duration.
---

View File

@@ -42,9 +42,11 @@ package "server/routes" {
- activeClients : Map<string, ClientInfo>
- CLIENT_STALE_MS : 30000
--
+ GET /stream (SSE, text/event-stream)
+ GET /user-downloads
+ GET /user-summary
+ GET /status
+ GET /cover-art
--
- getCoverArt(item) : string|null
- extractAllTags(tags, tagMap) : string[]
@@ -224,7 +226,8 @@ package "server/utils" {
class "ClientInfo" as ci <<value>> {
+ user : string
+ refreshRateMs : number
+ type : 'sse'
+ connectedAt : number (timestamp)
+ lastSeen : number (timestamp)
}
}

View File

@@ -30,7 +30,7 @@ package "Express Server" as server {
package "Routes" as routes {
[auth.js\n/api/auth\n(pre-CSRF)] as auth
[dashboard.js\n/api/dashboard] as dashboard
[dashboard.js\n/api/dashboard\n(+SSE /stream)] as dashboard
[emby.js\n/api/emby] as emby_route
[sabnzbd.js\n/api/sabnzbd] as sab_route
[sonarr.js\n/api/sonarr] as sonarr_route
@@ -86,6 +86,9 @@ package "Express Server" as server {
auth ..> sanitize
dashboard ..> sanitize
note "Browser uses EventSource\n(SSE) to /stream.\nServer pushes on each\npoll cycle — no client timer." as sseNote
sseNote .. dashboard
}
cloud "External Services" as external {