feat: replace client polling with Server-Sent Events (SSE)
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:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user