Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c847a26d3 | |||
| 28f2aa17d8 | |||
| aa8a6a49f4 | |||
| 0ffe62e1ca | |||
| 925d0c7735 | |||
| 121c49b35b | |||
| a4004f5e7a | |||
| fd0d5cf6ec | |||
| 1f293ae70b | |||
| 352118b4af | |||
| f41d14b2a9 | |||
| 240fc0d3b6 | |||
| c3ae3a80de | |||
| 94fe0dea4d | |||
| 3c3382401c | |||
| c86694fc8f | |||
| dcf613446e | |||
| 0d4b169c79 | |||
| 972c1b81ec | |||
| 7ff29b669c | |||
| 0dbf0e0899 | |||
| 67a8610843 | |||
| cafa608e8c | |||
| 35d50fad0a | |||
| 4af36fc926 | |||
| 0ea9b769a3 | |||
| abdd0da306 |
@@ -54,7 +54,7 @@ jobs:
|
||||
NODE_ENV: test
|
||||
|
||||
- name: Upload coverage report
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: coverage-report
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
name: Render PlantUML Diagrams
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "develop", "release/**"]
|
||||
paths:
|
||||
- "docs/diagrams/**.puml"
|
||||
|
||||
jobs:
|
||||
render:
|
||||
name: Render .puml → .png
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.BOT_TOKEN }}
|
||||
|
||||
- name: Install Java & Graphviz
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends default-jre-headless graphviz
|
||||
|
||||
- name: Download PlantUML jar
|
||||
run: |
|
||||
curl -sSL -o /usr/local/bin/plantuml.jar \
|
||||
https://github.com/plantuml/plantuml/releases/download/v1.2024.6/plantuml-1.2024.6.jar
|
||||
|
||||
- name: Render diagrams
|
||||
run: |
|
||||
java -jar /usr/local/bin/plantuml.jar -tpng -o . docs/diagrams/*.puml
|
||||
|
||||
- name: Commit rendered PNGs
|
||||
run: |
|
||||
git config user.name "gitea-actions[bot]"
|
||||
git config user.email "gitea-actions[bot]@i3omb.com"
|
||||
git add docs/diagrams/*.png
|
||||
if git diff --cached --quiet; then
|
||||
echo "No diagram changes to commit."
|
||||
else
|
||||
git commit -m "ci: render PlantUML diagrams [skip ci]"
|
||||
git push
|
||||
fi
|
||||
@@ -51,7 +51,7 @@ SONARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Docker** (recommended), or Node.js (v12+) for manual installation
|
||||
- **Docker** (recommended), or Node.js (v22+) for manual installation
|
||||
- At least one of: SABnzbd or qBittorrent
|
||||
- Sonarr (optional, for TV tracking)
|
||||
- Radarr (optional, for movie tracking)
|
||||
@@ -141,8 +141,8 @@ services:
|
||||
| Tag | Description |
|
||||
|-----|-------------|
|
||||
| `latest` | Latest stable release |
|
||||
| `0.1` | Latest patch for the 0.1.x release line |
|
||||
| `0.1.0` | Specific version |
|
||||
| `1.0` | Latest patch for the 1.0.x release line |
|
||||
| `1.0.0` | Specific version |
|
||||
|
||||
### Updating
|
||||
|
||||
@@ -245,11 +245,12 @@ sofarr polls all configured services in the background and caches the results. D
|
||||
| `POLL_INTERVAL=10000` | Poll every 10 seconds. |
|
||||
| `POLL_INTERVAL=0` / `off` / `disabled` | No background polling. Data fetched on-demand when a user opens the dashboard, then cached for 30 seconds. |
|
||||
|
||||
**On-demand mode** is useful for low-resource setups. When one user's browser refreshes, the fetched data is cached and served to all other users until it expires. A user with a faster refresh rate effectively keeps the cache warm for everyone.
|
||||
**On-demand mode** is useful for low-resource setups. When one user's browser opens the SSE stream it triggers a fresh poll; the fetched data is cached and served to all other connected clients until the next poll.
|
||||
|
||||
### Real-Time Updates
|
||||
- Auto-refresh every 5 seconds (configurable: 1s, 5s, 10s, or off)
|
||||
- **Server-Sent Events (SSE)** — the server pushes updates to the browser immediately after each poll cycle. No client-side timer; no wasted requests.
|
||||
- In-place DOM updates for smooth UI (no flickering)
|
||||
- Browser reconnects automatically on network interruption
|
||||
|
||||
### Download Information Displayed
|
||||
- **Progress bar** with visual completion percentage
|
||||
@@ -262,23 +263,28 @@ sofarr polls all configured services in the background and caches the results. D
|
||||
### For qBittorrent Downloads
|
||||
- **Seeds** - Number of seeders
|
||||
- **Peers** - Number of peers
|
||||
- **Availability** - Percentage available in swarm
|
||||
- **Availability** - Percentage available in swarm (shown in red when below 100%)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication
|
||||
- `POST /api/auth/login` - Login with Emby credentials
|
||||
- `POST /api/auth/logout` - Logout and clear session
|
||||
- `POST /api/auth/login` — Login with Emby credentials
|
||||
- `POST /api/auth/logout` — Logout and revoke session
|
||||
- `GET /api/auth/me` — Check current session
|
||||
- `GET /api/csrf` — Fetch a CSRF token
|
||||
|
||||
### Dashboard
|
||||
- `GET /api/dashboard/downloads` - Get all downloads for authenticated user
|
||||
- `GET /api/dashboard/stream` — **SSE stream**: pushes `{ user, isAdmin, downloads }` on every poll cycle
|
||||
- `GET /api/dashboard/user-downloads` — Single-request download fetch (no streaming)
|
||||
- `GET /api/dashboard/user-summary` — Per-user download counts (admin)
|
||||
- `GET /api/dashboard/status` — Server / polling / cache status (admin)
|
||||
- `GET /api/dashboard/cover-art` — Proxied cover art image
|
||||
|
||||
### Service APIs (proxy to your services)
|
||||
- `GET /api/sabnzbd/*` - SABnzbd API proxy
|
||||
- `GET /api/qbittorrent/*` - qBittorrent API proxy
|
||||
- `GET /api/sonarr/*` - Sonarr API proxy
|
||||
- `GET /api/radarr/*` - Radarr API proxy
|
||||
- `GET /api/emby/*` - Emby API proxy
|
||||
- `GET /api/sabnzbd/*` — SABnzbd API proxy
|
||||
- `GET /api/sonarr/*` — Sonarr API proxy
|
||||
- `GET /api/radarr/*` — Radarr API proxy
|
||||
- `GET /api/emby/*` — Emby API proxy
|
||||
|
||||
## Logging Levels
|
||||
|
||||
|
||||
+8
-2
@@ -4,7 +4,8 @@
|
||||
|
||||
| Version | Supported |
|
||||
|---------|-----------|
|
||||
| 0.2.x | ✅ Yes |
|
||||
| 1.0.x | ✅ Yes |
|
||||
| 0.2.x | ❌ No |
|
||||
| 0.1.x | ❌ No |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
@@ -113,6 +114,11 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Required for SSE (Server-Sent Events) — disable response buffering
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -123,7 +129,7 @@ server {
|
||||
|
||||
| Header | Value |
|
||||
|--------|-------|
|
||||
| `Content-Security-Policy` | `default-src 'self'; script-src 'self' 'nonce-…'; style-src 'self' 'nonce-…'; img-src 'self' data: blob:; object-src 'none'; frame-ancestors 'none'` |
|
||||
| `Content-Security-Policy` | `default-src 'self'; script-src 'self' 'nonce-…'; style-src 'self' 'nonce-…'; style-src-attr 'unsafe-inline'; img-src 'self' data: blob:; object-src 'none'; frame-ancestors 'none'` |
|
||||
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains; preload` (production only) |
|
||||
| `X-Content-Type-Options` | `nosniff` |
|
||||
| `X-Frame-Options` | `DENY` |
|
||||
|
||||
+63
-25
@@ -203,7 +203,7 @@ sofarr/
|
||||
| Module | Mount Point | Auth Required | CSRF Required | Purpose |
|
||||
|--------|------------|:-------------:|:-------------:|--------|
|
||||
| `auth.js` | `/api/auth` | No | No | Login, session check, CSRF token, logout |
|
||||
| `dashboard.js` | `/api/dashboard` | Yes (`requireAuth`) | Yes | Aggregated download data, status, cover-art proxy |
|
||||
| `dashboard.js` | `/api/dashboard` | Yes (`requireAuth`) | Yes (except `/stream` — GET) | SSE stream, aggregated download data, status, cover-art proxy |
|
||||
| `emby.js` | `/api/emby` | Yes (`requireAuth`) | Yes | Proxy to Emby API |
|
||||
| `sabnzbd.js` | `/api/sabnzbd` | Yes (`requireAuth`) | Yes | Proxy to SABnzbd API |
|
||||
| `sonarr.js` | `/api/sonarr` | Yes (`requireAuth`) | Yes | Proxy to Sonarr API |
|
||||
@@ -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`)
|
||||
|
||||
---
|
||||
|
||||
@@ -280,7 +293,7 @@ When a user requests `/api/dashboard/user-downloads`:
|
||||
6. Sets an `httpOnly` `emby_user` cookie containing only `{ id, name, isAdmin }`:
|
||||
- **`rememberMe: true`** → persistent cookie, `Max-Age` 30 days
|
||||
- **`rememberMe: false`** → session cookie (no `Max-Age`; expires when browser closes)
|
||||
- `secure` flag enabled when `NODE_ENV=production`
|
||||
- `secure` flag enabled when `TRUST_PROXY` is set (i.e. a TLS-terminating reverse proxy is in front)
|
||||
- Signed with HMAC when `COOKIE_SECRET` is set
|
||||
7. Issues a `csrf_token` cookie (`httpOnly: false` so JS can read it) containing a 32-byte random hex token
|
||||
8. Returns `{ success: true, user, csrfToken }` — the SPA stores `csrfToken` in memory and sends it as `X-CSRF-Token` on all subsequent state-changing requests
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -619,7 +657,7 @@ The dashboard polls the server at the user-selected interval (1s, 5s, 10s, or Of
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|:--------:|---------|-------------|
|
||||
| `PORT` | No | `3001` | Server listen port |
|
||||
| `NODE_ENV` | No | — | Set to `production` to enable `secure` cookies and HTTPS upgrades |
|
||||
| `NODE_ENV` | No | — | Set to `production` for production logging and startup validation. Does **not** control cookie `secure` flag or CSP `upgrade-insecure-requests` (both gated on `TRUST_PROXY`). |
|
||||
| `DATA_DIR` | No | `./data` | Directory for `tokens.json` and `server.log`. Must be writable by the server process. In Docker: `/app/data` (named volume). |
|
||||
| `COOKIE_SECRET` | No | — | If set, signs all session cookies with HMAC-SHA256. Strongly recommended in production. |
|
||||
| `TRUST_PROXY` | No | — | Express `trust proxy` setting. Set to `1` (or a hop count) when behind a reverse proxy (nginx, Traefik, etc.) so `req.ip` and `req.secure` are correct. |
|
||||
@@ -686,7 +724,7 @@ The production image uses a two-stage build on `node:22-alpine`:
|
||||
2. **`runtime` stage** — copies `node_modules`, `server/`, `public/`, and `package.json`. Runs as the built-in non-root `node` user (UID 1000). `/app/data` is owned by `node` for writable token store and logs.
|
||||
|
||||
Key environment variables set in the image:
|
||||
- `NODE_ENV=production` — enables secure cookies and HTTPS upgrade CSP directive
|
||||
- `NODE_ENV=production` — enables production startup validation and logging
|
||||
- `DATA_DIR=/app/data` — token store and log file location
|
||||
|
||||
### Docker Compose
|
||||
@@ -724,7 +762,7 @@ volumes:
|
||||
- **Set `COOKIE_SECRET`** — enables HMAC-signed cookies, preventing client-side forgery.
|
||||
- **Set `TRUST_PROXY=1`** when behind a reverse proxy — ensures `req.secure` is `true` so the `secure` cookie flag is enforced and HTTPS-upgrade CSP fires.
|
||||
- **Mount a named volume** for `DATA_DIR` — token store and log file survive container recreates.
|
||||
- **Use HTTPS** — the CSP includes `upgrade-insecure-requests` in production and the HSTS header is set with a 1-year `maxAge`.
|
||||
- **Use HTTPS** — set `TRUST_PROXY=1` to enable the CSP `upgrade-insecure-requests` directive, the `secure` cookie flag, and HSTS (1-year `maxAge`).
|
||||
- **Rate limiting** — the login endpoint is limited to 10 failed attempts per IP per 15 minutes; all API endpoints share a 300 req/15 min window. Set `TRUST_PROXY` correctly so the client IP is not the proxy's IP.
|
||||
|
||||
### CI / CD
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@startuml seq-dashboard
|
||||
!theme plain
|
||||
title sofarr — Dashboard Request Sequence
|
||||
title sofarr — Dashboard SSE Stream Sequence
|
||||
|
||||
actor User as user
|
||||
participant "Browser\n(app.js)" as browser
|
||||
@@ -9,47 +9,28 @@ participant "MemoryCache" as cache
|
||||
participant "Poller" as poller
|
||||
participant "External\nServices" as ext
|
||||
|
||||
== Periodic Refresh (or Initial Load) ==
|
||||
user -> browser : (auto-refresh fires)
|
||||
== SSE Connection (on login / page load) ==
|
||||
user -> browser : Login success\nor valid session
|
||||
activate browser
|
||||
browser -> dashboard : GET /api/dashboard/user-downloads\n?refreshRate=5000&showAll=false
|
||||
browser -> dashboard : GET /api/dashboard/stream\n(EventSource, Cookie: emby_user)
|
||||
activate dashboard
|
||||
|
||||
dashboard -> dashboard : Parse emby_user cookie\nExtract username, isAdmin
|
||||
dashboard -> dashboard : Track client refresh rate\nin activeClients Map
|
||||
dashboard -> dashboard : requireAuth: parse cookie\nextract username, isAdmin
|
||||
dashboard -> dashboard : Set headers:\nContent-Type: text/event-stream\nX-Accel-Buffering: no
|
||||
dashboard -> dashboard : Register in activeClients Map\n{ user, type:'sse', connectedAt }
|
||||
|
||||
alt Polling disabled AND cache empty
|
||||
dashboard -> poller : pollAllServices()
|
||||
activate poller
|
||||
poller -> ext : Parallel API calls\n(SAB, Sonarr, Radarr, qBit)
|
||||
poller -> ext : Parallel API calls
|
||||
ext --> poller : Raw data
|
||||
poller -> cache : set poll:* keys\n(TTL = 30s)
|
||||
poller -> cache : set poll:* keys (TTL=30s)
|
||||
deactivate poller
|
||||
end
|
||||
|
||||
dashboard -> cache : get('poll:sab-queue')
|
||||
cache --> dashboard : { slots, status, speed }
|
||||
dashboard -> cache : get('poll:sab-history')
|
||||
cache --> dashboard : { slots }
|
||||
dashboard -> cache : get('poll:sonarr-tags')
|
||||
cache --> dashboard : [{ instance, data }]
|
||||
dashboard -> cache : get('poll:sonarr-queue')
|
||||
cache --> dashboard : { records } (with embedded series)
|
||||
dashboard -> cache : get('poll:sonarr-history')
|
||||
cache --> dashboard : { records }
|
||||
dashboard -> cache : get('poll:radarr-queue')
|
||||
cache --> dashboard : { records } (with embedded movie)
|
||||
dashboard -> cache : get('poll:radarr-history')
|
||||
cache --> dashboard : { records }
|
||||
dashboard -> cache : get('poll:radarr-tags')
|
||||
cache --> dashboard : [{id, label}]
|
||||
dashboard -> cache : get('poll:qbittorrent')
|
||||
cache --> dashboard : [torrent, ...]
|
||||
|
||||
dashboard -> dashboard : Build seriesMap from\nSonarr queue records
|
||||
dashboard -> dashboard : Build moviesMap from\nRadarr queue records
|
||||
dashboard -> dashboard : Build tag maps\n(id → label)
|
||||
|
||||
== Initial Payload (sent immediately on connect) ==
|
||||
dashboard -> cache : get all poll:* keys
|
||||
dashboard -> dashboard : Build seriesMap, moviesMap,\nsonarrTagMap, radarrTagMap
|
||||
alt showAll=true
|
||||
dashboard -> cache : get('emby:users')
|
||||
alt cache miss
|
||||
@@ -57,44 +38,30 @@ alt showAll=true
|
||||
ext --> dashboard : [{ Name, ... }]
|
||||
dashboard -> cache : set('emby:users', map, 60s)
|
||||
end
|
||||
dashboard -> dashboard : Build embyUserMap\n(lowerName → displayName)
|
||||
end
|
||||
dashboard -> dashboard : Match SABnzbd/qBit downloads\nvs Sonarr/Radarr records\nextractUserTag / buildTagBadges
|
||||
dashboard --> browser : data: { user, isAdmin, downloads }
|
||||
browser -> browser : hideLoading()\nrenderDownloads()
|
||||
|
||||
== Pushed Updates (on every poll cycle) ==
|
||||
loop Each poll cycle completes
|
||||
poller -> poller : pollAllServices() complete
|
||||
poller -> dashboard : onPollComplete callback fires
|
||||
dashboard -> cache : get all poll:* keys
|
||||
dashboard -> dashboard : Rebuild download payload
|
||||
dashboard --> browser : data: { user, isAdmin, downloads }
|
||||
browser -> browser : renderDownloads() (diff-based)
|
||||
end
|
||||
|
||||
group SABnzbd Queue Matching
|
||||
loop each queue slot
|
||||
dashboard -> dashboard : Match title vs Sonarr queue
|
||||
dashboard -> dashboard : Match title vs Radarr queue
|
||||
dashboard -> dashboard : extractAllTags() + extractUserTag(username)\nInclude if: showAll+anyTag OR matchedUserTag\nAttach allTags, matchedUserTag\nIf showAll: tagBadges = buildTagBadges(embyUserMap)
|
||||
end
|
||||
end
|
||||
== Heartbeat (every 25s) ==
|
||||
dashboard --> browser : : heartbeat
|
||||
note right : Keeps connection alive\nthrough idle-timeout proxies
|
||||
|
||||
group SABnzbd History Matching
|
||||
loop each history slot
|
||||
dashboard -> dashboard : Match title vs Sonarr/Radarr history
|
||||
dashboard -> dashboard : Same tag extraction + inclusion logic
|
||||
end
|
||||
end
|
||||
|
||||
group qBittorrent Matching
|
||||
loop each torrent
|
||||
dashboard -> dashboard : 1. Match vs Sonarr queue
|
||||
dashboard -> dashboard : 2. Match vs Radarr queue
|
||||
dashboard -> dashboard : 3. Match vs Sonarr history
|
||||
dashboard -> dashboard : 4. Match vs Radarr history
|
||||
dashboard -> dashboard : mapTorrentToDownload()\n→ enrich: allTags, matchedUserTag, tagBadges
|
||||
end
|
||||
end
|
||||
|
||||
dashboard --> browser : { user, isAdmin,\ndownloads: [...] }
|
||||
== Client Disconnects ==
|
||||
user -> browser : Close tab / logout
|
||||
browser -> dashboard : TCP close (req 'close' event)
|
||||
dashboard -> dashboard : offPollComplete(callback)\nclearInterval(heartbeat)\ndelete activeClients[key]
|
||||
deactivate dashboard
|
||||
|
||||
browser -> browser : renderDownloads() (diff-based)
|
||||
note right
|
||||
createDownloadCard() renders tag badges:
|
||||
- Normal: accent badge for matchedUserTag
|
||||
- showAll: amber badges (unmatched tags)
|
||||
accent badges (matched → show Emby displayName)
|
||||
end note
|
||||
deactivate browser
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -82,6 +82,10 @@ poller -> cache : set('poll:radarr-history', ..., cacheTTL)
|
||||
poller -> cache : set('poll:radarr-tags', ..., cacheTTL)
|
||||
poller -> cache : set('poll:qbittorrent', ..., cacheTTL)
|
||||
|
||||
poller -> poller : Notify SSE subscribers\npollSubscribers.forEach(cb => cb())
|
||||
|
||||
note over poller : Each registered SSE client\ncallback rebuilds its payload\nand writes a data: frame
|
||||
|
||||
poller -> poller : polling = false\nlog elapsed time
|
||||
|
||||
deactivate poller
|
||||
|
||||
@@ -32,7 +32,9 @@ state Polling {
|
||||
lock --> fetching
|
||||
fetching --> storing : All promises resolved
|
||||
fetching --> ErrorState : Any individual service\nerror (caught per-service)
|
||||
storing --> timing
|
||||
storing --> notifying : Cache updated
|
||||
state "Notifying SSE\nsubscribers" as notifying
|
||||
notifying --> timing
|
||||
timing --> [*] : polling = false
|
||||
}
|
||||
|
||||
|
||||
+18
-24
@@ -32,48 +32,42 @@ state FadeOutLogin {
|
||||
FadeOutLogin --> SplashScreen2 : Show splash\nwhile loading
|
||||
|
||||
state SplashScreen2 as "Splash (loading data)" {
|
||||
state "fetchUserDownloads()" as fetching
|
||||
state "startSSE() — awaiting\nfirst SSE message" as fetching
|
||||
}
|
||||
|
||||
SplashScreen2 --> Dashboard : Data loaded\ndismissSplash()
|
||||
|
||||
state Dashboard {
|
||||
state "Rendering Cards" as rendering
|
||||
state "Auto Refreshing" as refreshing
|
||||
state "Status Panel Open" as status_open
|
||||
state "Status Panel Closed" as status_closed
|
||||
|
||||
[*] --> rendering
|
||||
rendering --> refreshing : startAutoRefresh()
|
||||
refreshing --> rendering : fetchUserDownloads()\n→ renderDownloads()
|
||||
rendering --> rendering : SSE message received
|
||||
→ renderDownloads()
|
||||
rendering --> rendering : Theme change
|
||||
|
||||
status_closed --> status_open : Click "Status" btn\n(admin only)
|
||||
status_closed --> status_open : Click "Status" btn
|
||||
(admin only)
|
||||
status_open --> status_closed : Click close (×)
|
||||
status_open --> status_open : Auto-refresh\nrenderStatusPanel()
|
||||
status_open --> status_open : 5s timer
|
||||
→ renderStatusPanel()
|
||||
|
||||
[*] --> status_closed
|
||||
|
||||
state "Refresh Rate" as rr {
|
||||
state "1s" as r1
|
||||
state "5s (default)" as r5
|
||||
state "10s" as r10
|
||||
state "Off" as roff
|
||||
r5 --> r1 : User selects
|
||||
r5 --> r10
|
||||
r5 --> roff
|
||||
r1 --> r5
|
||||
r1 --> r10
|
||||
r1 --> roff
|
||||
r10 --> r1
|
||||
r10 --> r5
|
||||
r10 --> roff
|
||||
roff --> r1
|
||||
roff --> r5
|
||||
roff --> r10
|
||||
state "SSE Connection" as sse {
|
||||
state "Connecting" as sc
|
||||
state "Connected" as scon
|
||||
state "Reconnecting" as srec
|
||||
sc --> scon : First message received
|
||||
scon --> srec : Connection lost
|
||||
srec --> scon : Browser auto-reconnects
|
||||
scon --> sc : showAll toggle changed
|
||||
}
|
||||
}
|
||||
|
||||
Dashboard --> LoginForm : Logout\n(clear cookie,\nstopAutoRefresh)
|
||||
Dashboard --> LoginForm : Logout
|
||||
(stopSSE,
|
||||
clear state)
|
||||
|
||||
@enduml
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "0.2.0",
|
||||
"version": "1.0.0",
|
||||
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
|
||||
"main": "server/index.js",
|
||||
"scripts": {
|
||||
|
||||
+79
-93
@@ -1,12 +1,15 @@
|
||||
let currentUser = null;
|
||||
let downloads = [];
|
||||
let refreshInterval = null;
|
||||
let currentRefreshRate = 5000; // default 5 seconds
|
||||
let isAdmin = false;
|
||||
let showAll = false;
|
||||
let csrfToken = null; // double-submit CSRF token, sent as X-CSRF-Token on mutating requests
|
||||
const SPLASH_MIN_MS = 1200; // minimum splash display time
|
||||
|
||||
// SSE stream state
|
||||
let sseSource = null;
|
||||
let sseReconnectTimer = null;
|
||||
const SSE_RECONNECT_MS = 3000; // browser already retries, but we add explicit backoff too
|
||||
|
||||
// Apply saved theme immediately (before DOMContentLoaded to avoid flash)
|
||||
(function() {
|
||||
const saved = localStorage.getItem('sofarr-theme') || 'light';
|
||||
@@ -20,7 +23,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
document.getElementById('login-form').addEventListener('submit', handleLogin);
|
||||
document.getElementById('logout-btn').addEventListener('click', handleLogout);
|
||||
document.getElementById('refresh-rate').addEventListener('change', handleRefreshRateChange);
|
||||
document.getElementById('show-all-toggle').addEventListener('change', handleShowAllToggle);
|
||||
document.getElementById('status-btn').addEventListener('click', toggleStatusPanel);
|
||||
});
|
||||
@@ -41,37 +43,51 @@ function setTheme(theme) {
|
||||
});
|
||||
}
|
||||
|
||||
function startAutoRefresh() {
|
||||
if (refreshInterval) clearInterval(refreshInterval);
|
||||
if (currentRefreshRate > 0) {
|
||||
refreshInterval = setInterval(() => fetchUserDownloads(false), currentRefreshRate);
|
||||
}
|
||||
// --- SSE connection management ---
|
||||
|
||||
function startSSE() {
|
||||
stopSSE();
|
||||
const params = showAll ? '?showAll=true' : '';
|
||||
const source = new EventSource('/api/dashboard/stream' + params);
|
||||
sseSource = source;
|
||||
|
||||
let firstMessage = true;
|
||||
source.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
currentUser = data.user;
|
||||
isAdmin = !!data.isAdmin;
|
||||
downloads = data.downloads;
|
||||
document.getElementById('currentUser').textContent = currentUser || '-';
|
||||
renderDownloads();
|
||||
hideError();
|
||||
if (firstMessage) { firstMessage = false; hideLoading(); }
|
||||
} catch (err) {
|
||||
console.error('[SSE] Failed to parse message:', err);
|
||||
}
|
||||
};
|
||||
|
||||
source.onerror = () => {
|
||||
// EventSource retries automatically; we just log and show a reconnecting indicator
|
||||
console.warn('[SSE] Connection lost, browser will retry...');
|
||||
};
|
||||
|
||||
console.log('[SSE] Stream connected');
|
||||
}
|
||||
|
||||
function handleRefreshRateChange(e) {
|
||||
const rate = parseInt(e.target.value);
|
||||
currentRefreshRate = rate;
|
||||
startAutoRefresh();
|
||||
// Restart status panel refresh if it's open
|
||||
const statusPanel = document.getElementById('status-panel');
|
||||
if (statusPanel && statusPanel.style.display !== 'none') {
|
||||
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; }
|
||||
if (currentRefreshRate > 0) {
|
||||
statusRefreshHandle = setInterval(refreshStatusPanel, currentRefreshRate);
|
||||
}
|
||||
function stopSSE() {
|
||||
if (sseReconnectTimer) { clearTimeout(sseReconnectTimer); sseReconnectTimer = null; }
|
||||
if (sseSource) {
|
||||
sseSource.close();
|
||||
sseSource = null;
|
||||
console.log('[SSE] Stream closed');
|
||||
}
|
||||
}
|
||||
|
||||
function handleShowAllToggle(e) {
|
||||
showAll = e.target.checked;
|
||||
fetchUserDownloads(true);
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
refreshInterval = null;
|
||||
}
|
||||
// Re-open stream with updated showAll param
|
||||
startSSE();
|
||||
}
|
||||
|
||||
function fadeOutLogin() {
|
||||
@@ -132,8 +148,8 @@ async function checkAuthentication() {
|
||||
currentUser = data.user;
|
||||
isAdmin = !!data.user.isAdmin;
|
||||
showDashboard();
|
||||
await fetchUserDownloads(true);
|
||||
startAutoRefresh();
|
||||
showLoading();
|
||||
startSSE();
|
||||
await dismissSplash(splashStart);
|
||||
} else {
|
||||
await dismissSplash(splashStart);
|
||||
@@ -169,7 +185,7 @@ async function handleLogin(e) {
|
||||
isAdmin = !!data.user.isAdmin;
|
||||
// Store CSRF token returned by login for use in subsequent requests
|
||||
if (data.csrfToken) csrfToken = data.csrfToken;
|
||||
// Fade out login, then show splash while loading data.
|
||||
// Fade out login, then show splash while opening SSE stream.
|
||||
// requestAnimationFrame ensures the browser paints the splash at
|
||||
// opacity:1 before dismissSplash adds fade-out, so the CSS
|
||||
// transition fires and transitionend is guaranteed.
|
||||
@@ -177,9 +193,9 @@ async function handleLogin(e) {
|
||||
showSplash();
|
||||
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
showDashboard();
|
||||
showLoading();
|
||||
const splashStart = Date.now();
|
||||
await fetchUserDownloads(true);
|
||||
startAutoRefresh();
|
||||
startSSE();
|
||||
await dismissSplash(splashStart);
|
||||
} else {
|
||||
showLoginError(data.error || 'Login failed');
|
||||
@@ -192,7 +208,8 @@ async function handleLogin(e) {
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
stopAutoRefresh();
|
||||
stopSSE();
|
||||
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; }
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: csrfToken ? { 'X-CSRF-Token': csrfToken } : {}
|
||||
@@ -216,6 +233,10 @@ function showDashboard() {
|
||||
document.getElementById('login-container').style.display = 'none';
|
||||
document.getElementById('dashboard-container').style.display = 'block';
|
||||
document.getElementById('currentUser').textContent = currentUser.name || '-';
|
||||
// Always start with status panel hidden (guards against stale display value on re-login)
|
||||
const sp = document.getElementById('status-panel');
|
||||
sp.style.display = 'none';
|
||||
sp.innerHTML = '';
|
||||
document.getElementById('admin-controls').style.display = isAdmin ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
@@ -230,40 +251,8 @@ function hideLoginError() {
|
||||
errorDiv.style.display = 'none';
|
||||
}
|
||||
|
||||
async function fetchUserDownloads(isInitialLoad = false) {
|
||||
if (isInitialLoad) {
|
||||
showLoading();
|
||||
}
|
||||
hideError();
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (showAll) params.set('showAll', 'true');
|
||||
params.set('refreshRate', currentRefreshRate);
|
||||
const url = '/api/dashboard/user-downloads?' + params.toString();
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
currentUser = data.user;
|
||||
isAdmin = !!data.isAdmin;
|
||||
downloads = data.downloads;
|
||||
|
||||
// Debug: log first download to see what fields are present
|
||||
if (downloads.length > 0) {
|
||||
console.log('[Dashboard] Download data:', JSON.stringify(downloads[0]));
|
||||
}
|
||||
|
||||
document.getElementById('currentUser').textContent = currentUser || '-';
|
||||
renderDownloads();
|
||||
} catch (err) {
|
||||
showError('Failed to fetch downloads. Make sure all services are configured.');
|
||||
console.error(err);
|
||||
} finally {
|
||||
if (isInitialLoad) {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
}
|
||||
// fetchUserDownloads is kept for the showAll toggle re-connection case
|
||||
// but the primary data path is now via SSE (startSSE / EventSource).
|
||||
|
||||
function renderDownloads() {
|
||||
const downloadsList = document.getElementById('downloads-list');
|
||||
@@ -370,9 +359,10 @@ function updateDownloadCard(card, download) {
|
||||
peersEl.textContent = download.peers;
|
||||
}
|
||||
|
||||
const availabilityEl = card.querySelector('.detail-item[data-label="Availability"] .detail-value');
|
||||
if (availabilityEl && download.availability !== undefined) {
|
||||
availabilityEl.textContent = `${download.availability}%`;
|
||||
const availabilityItem = card.querySelector('.detail-item[data-label="Availability"]');
|
||||
if (availabilityItem && download.availability !== undefined) {
|
||||
availabilityItem.querySelector('.detail-value').textContent = `${download.availability}%`;
|
||||
availabilityItem.classList.toggle('availability-warning', parseFloat(download.availability) < 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -569,6 +559,7 @@ function createDownloadCard(download) {
|
||||
|
||||
if (download.availability !== undefined) {
|
||||
const availability = createDetailItem('Availability', `${download.availability}%`);
|
||||
if (parseFloat(download.availability) < 100) availability.classList.add('availability-warning');
|
||||
details.appendChild(availability);
|
||||
}
|
||||
}
|
||||
@@ -628,6 +619,7 @@ function escapeHtml(str) {
|
||||
}
|
||||
|
||||
let statusRefreshHandle = null;
|
||||
const STATUS_REFRESH_MS = 5000;
|
||||
|
||||
async function toggleStatusPanel() {
|
||||
const panel = document.getElementById('status-panel');
|
||||
@@ -638,11 +630,8 @@ async function toggleStatusPanel() {
|
||||
}
|
||||
panel.style.display = 'block';
|
||||
await refreshStatusPanel();
|
||||
// Auto-refresh in sync with dashboard refresh rate
|
||||
if (statusRefreshHandle) clearInterval(statusRefreshHandle);
|
||||
if (currentRefreshRate > 0) {
|
||||
statusRefreshHandle = setInterval(refreshStatusPanel, currentRefreshRate);
|
||||
}
|
||||
statusRefreshHandle = setInterval(refreshStatusPanel, STATUS_REFRESH_MS);
|
||||
}
|
||||
|
||||
function closeStatusPanel() {
|
||||
@@ -693,11 +682,7 @@ function renderStatusPanel(data, panel) {
|
||||
|
||||
const pollIntervalMs = data.polling.intervalMs;
|
||||
const clients = data.clients || [];
|
||||
const activeRefreshers = clients.filter(c => c.refreshRateMs > 0);
|
||||
const fastestClient = activeRefreshers.length > 0
|
||||
? activeRefreshers.reduce((min, c) => c.refreshRateMs < min.refreshRateMs ? c : min)
|
||||
: null;
|
||||
const hasForegroundClient = fastestClient && data.polling.enabled && fastestClient.refreshRateMs < pollIntervalMs;
|
||||
const sseClients = clients.filter(c => c.type === 'sse');
|
||||
|
||||
if (data.polling.enabled) {
|
||||
html += `<div class="status-row"><span>Background poll</span><span>${pollIntervalMs / 1000}s</span></div>`;
|
||||
@@ -705,19 +690,15 @@ function renderStatusPanel(data, panel) {
|
||||
html += `<div class="status-row"><span>Background poll</span><span>Disabled</span></div>`;
|
||||
}
|
||||
|
||||
if (hasForegroundClient) {
|
||||
html += `<div class="status-row"><span>Effective mode</span><span class="status-fg-badge">Foreground ${fastestClient.refreshRateMs / 1000}s</span></div>`;
|
||||
} else if (activeRefreshers.length > 0) {
|
||||
html += `<div class="status-row"><span>Effective mode</span><span>${data.polling.enabled ? 'Background' : 'On-demand'}</span></div>`;
|
||||
} else {
|
||||
html += `<div class="status-row"><span>Effective mode</span><span>Idle (no active clients)</span></div>`;
|
||||
}
|
||||
const mode = sseClients.length > 0
|
||||
? `<span class="status-fg-badge">SSE push</span>`
|
||||
: (data.polling.enabled ? 'Background' : 'On-demand (idle)');
|
||||
html += `<div class="status-row"><span>Delivery mode</span><span>${mode}</span></div>`;
|
||||
|
||||
html += `<div class="status-row"><span>Active clients</span><span>${clients.length}</span></div>`;
|
||||
for (const c of clients) {
|
||||
const rate = c.refreshRateMs > 0 ? (c.refreshRateMs / 1000) + 's' : 'Off';
|
||||
const age = Math.round((Date.now() - c.lastSeen) / 1000);
|
||||
html += `<div class="status-row status-row-sub"><span>${escapeHtml(c.user)}</span><span>${rate} (${age}s ago)</span></div>`;
|
||||
html += `<div class="status-row"><span>SSE clients</span><span>${sseClients.length}</span></div>`;
|
||||
for (const c of sseClients) {
|
||||
const age = Math.round((Date.now() - c.connectedAt) / 1000);
|
||||
html += `<div class="status-row status-row-sub"><span>${escapeHtml(c.user)}</span><span>connected ${age}s ago</span></div>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
@@ -730,12 +711,13 @@ function renderStatusPanel(data, panel) {
|
||||
<div class="status-card status-card-wide">
|
||||
<div class="status-card-title">Last Poll (${lp.totalMs}ms total, ${pollAge}s ago)</div>
|
||||
<div class="status-timings">`;
|
||||
const maxTaskMs = lp.tasks.reduce((max, t) => Math.max(max, t.ms), 1);
|
||||
for (const t of lp.tasks) {
|
||||
const barWidth = lp.totalMs > 0 ? Math.max(2, (t.ms / lp.totalMs) * 100) : 0;
|
||||
const barWidth = Math.max(2, (t.ms / maxTaskMs) * 100);
|
||||
html += `
|
||||
<div class="timing-row">
|
||||
<span class="timing-label">${escapeHtml(t.label)}</span>
|
||||
<div class="timing-bar-bg"><div class="timing-bar" style="width:${barWidth.toFixed(1)}%"></div></div>
|
||||
<div class="timing-bar-bg"><div class="timing-bar" data-w="${barWidth.toFixed(1)}"></div></div>
|
||||
<span class="timing-value">${t.ms}ms</span>
|
||||
</div>`;
|
||||
}
|
||||
@@ -759,6 +741,10 @@ function renderStatusPanel(data, panel) {
|
||||
|
||||
html += `</tbody></table></div></div>`;
|
||||
panel.innerHTML = html;
|
||||
// Set bar widths via JS DOM assignment — immune to CSP style-src restrictions
|
||||
panel.querySelectorAll('.timing-bar[data-w]').forEach(el => {
|
||||
el.style.width = el.dataset.w + '%';
|
||||
});
|
||||
}
|
||||
|
||||
function formatSize(size) {
|
||||
|
||||
@@ -53,15 +53,6 @@
|
||||
<button class="theme-btn" data-theme="dark">Dark</button>
|
||||
<button class="theme-btn" data-theme="mono">Mono</button>
|
||||
</div>
|
||||
<div class="refresh-control">
|
||||
<label for="refresh-rate">Refresh:</label>
|
||||
<select id="refresh-rate">
|
||||
<option value="1000">1s</option>
|
||||
<option value="5000" selected>5s</option>
|
||||
<option value="10000">10s</option>
|
||||
<option value="0">Off</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="admin-controls" class="admin-controls" style="display: none;">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="show-all-toggle">
|
||||
|
||||
+22
-9
@@ -462,17 +462,26 @@ body {
|
||||
.download-details {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 14px;
|
||||
gap: 4px 6px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid var(--border);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
gap: 3px;
|
||||
font-size: 0.78rem;
|
||||
background: var(--bg-secondary, rgba(0,0,0,0.04));
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail-item.availability-warning .detail-value {
|
||||
color: var(--danger, #e53e3e);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
@@ -491,10 +500,17 @@ body {
|
||||
/* ===== Progress Bar (Compact) ===== */
|
||||
.progress-item {
|
||||
flex-basis: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: none;
|
||||
padding: 0;
|
||||
white-space: normal;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -510,13 +526,15 @@ body {
|
||||
}
|
||||
|
||||
.progress-segment {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-segment.downloaded {
|
||||
background: linear-gradient(90deg, var(--progress-fill-start) 0%, var(--progress-fill-end) 100%);
|
||||
float: left;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
@@ -1021,11 +1039,6 @@ body {
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.download-details {
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
+2
-1
@@ -42,6 +42,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
|
||||
styleSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
|
||||
styleSrcAttr: ["'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:', 'blob:'],
|
||||
fontSrc: ["'self'", 'data:'],
|
||||
connectSrc: ["'self'"],
|
||||
@@ -49,7 +50,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
||||
baseUri: ["'self'"],
|
||||
frameAncestors: ["'none'"],
|
||||
formAction: ["'self'"],
|
||||
upgradeInsecureRequests: process.env.NODE_ENV === 'production' ? [] : null
|
||||
upgradeInsecureRequests: process.env.TRUST_PROXY ? [] : null
|
||||
}
|
||||
},
|
||||
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
|
||||
|
||||
+18
-7
@@ -137,7 +137,7 @@ app.use((req, res, next) => {
|
||||
baseUri: ["'self'"],
|
||||
frameAncestors: ["'none'"],
|
||||
formAction: ["'self'"],
|
||||
upgradeInsecureRequests: process.env.NODE_ENV === 'production' ? [] : null
|
||||
upgradeInsecureRequests: process.env.TRUST_PROXY ? [] : null
|
||||
}
|
||||
},
|
||||
hsts: {
|
||||
@@ -202,18 +202,29 @@ app.get('/ready', (req, res) => {
|
||||
const PUBLIC_DIR = path.join(__dirname, '../public');
|
||||
const INDEX_HTML = path.join(PUBLIC_DIR, 'index.html');
|
||||
|
||||
// Serve all static assets (js, css, images, icons) except index.html
|
||||
app.use(express.static(PUBLIC_DIR, { index: false }));
|
||||
// Serve all static assets (js, css, images, icons) except index.html.
|
||||
// JS and CSS get no-cache so browsers revalidate on every load (ETag still
|
||||
// avoids re-downloading unchanged files; only a deploy changes the ETag).
|
||||
app.use(express.static(PUBLIC_DIR, {
|
||||
index: false,
|
||||
setHeaders(res, filePath) {
|
||||
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Serve index.html with nonce injected into the <script> and <link> tags
|
||||
// Serve index.html with CSP nonce injected into <script> tags
|
||||
function serveIndex(req, res) {
|
||||
fs.readFile(INDEX_HTML, 'utf8', (err, html) => {
|
||||
if (err) return res.status(500).send('Internal Server Error');
|
||||
const nonce = res.locals.cspNonce;
|
||||
// Inject nonce into <script> and <link rel="stylesheet"> tags
|
||||
// Only inject nonce into <script> tags — style-src 'self' already permits
|
||||
// same-origin <link rel=stylesheet> without a nonce, and injecting a nonce
|
||||
// onto <link> breaks mobile browsers / caching proxies (stale HTML carries
|
||||
// the old nonce which no longer matches the per-request CSP header).
|
||||
const patched = html
|
||||
.replace(/<script([^>]*)>/gi, `<script nonce="${nonce}"$1>`)
|
||||
.replace(/<link([^>]*rel=["']stylesheet["'][^>]*)>/gi, `<link nonce="${nonce}"$1>`);
|
||||
.replace(/<script([^>]*)>/gi, `<script nonce="${nonce}"$1>`);
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.send(patched);
|
||||
});
|
||||
|
||||
@@ -70,13 +70,15 @@ router.post('/login', loginLimiter, async (req, res) => {
|
||||
// Set authentication cookie (signed when COOKIE_SECRET is set).
|
||||
// rememberMe=true → persistent cookie, expires in 30 days
|
||||
// rememberMe=false → session cookie, expires when browser closes
|
||||
// secure is always true — the app should sit behind HTTPS in production;
|
||||
// behind a reverse proxy set TRUST_PROXY=1 so req.secure works correctly.
|
||||
// secure:true only when TRUST_PROXY is set — i.e. a TLS-terminating reverse
|
||||
// proxy is in front. Without it the app may be accessed over plain HTTP and
|
||||
// secure cookies would never be sent back by the browser.
|
||||
const cookiePayload = JSON.stringify({ id: user.Id, name: user.Name, isAdmin });
|
||||
const signed = !!process.env.COOKIE_SECRET;
|
||||
const secureCookie = !!process.env.TRUST_PROXY; // only send over HTTPS when behind a TLS proxy
|
||||
const cookieOptions = {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
secure: secureCookie,
|
||||
sameSite: 'strict',
|
||||
signed,
|
||||
path: '/'
|
||||
@@ -91,7 +93,7 @@ router.post('/login', loginLimiter, async (req, res) => {
|
||||
const csrfToken = crypto.randomBytes(32).toString('hex');
|
||||
res.cookie('csrf_token', csrfToken, {
|
||||
httpOnly: false, // intentionally readable by JS for the double-submit pattern
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
secure: secureCookie,
|
||||
sameSite: 'strict',
|
||||
path: '/'
|
||||
});
|
||||
@@ -142,7 +144,7 @@ router.get('/csrf', (req, res) => {
|
||||
const csrfToken = crypto.randomBytes(32).toString('hex');
|
||||
res.cookie('csrf_token', csrfToken, {
|
||||
httpOnly: false,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
secure: !!process.env.TRUST_PROXY,
|
||||
sameSite: 'strict',
|
||||
path: '/'
|
||||
});
|
||||
@@ -168,14 +170,14 @@ router.post('/logout', async (req, res) => {
|
||||
}
|
||||
res.clearCookie('emby_user', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
secure: !!process.env.TRUST_PROXY,
|
||||
sameSite: 'strict',
|
||||
signed: !!process.env.COOKIE_SECRET,
|
||||
path: '/'
|
||||
});
|
||||
res.clearCookie('csrf_token', {
|
||||
httpOnly: false,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
secure: !!process.env.TRUST_PROXY,
|
||||
sameSite: 'strict',
|
||||
path: '/'
|
||||
});
|
||||
|
||||
+273
-5
@@ -5,7 +5,7 @@ const requireAuth = require('../middleware/requireAuth');
|
||||
const axios = require('axios');
|
||||
const { mapTorrentToDownload } = require('../utils/qbittorrent');
|
||||
const cache = require('../utils/cache');
|
||||
const { pollAllServices, getLastPollTimings, POLLING_ENABLED } = require('../utils/poller');
|
||||
const { pollAllServices, getLastPollTimings, onPollComplete, offPollComplete, POLLING_ENABLED } = require('../utils/poller');
|
||||
const { getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
||||
const sanitizeError = require('../utils/sanitizeError');
|
||||
|
||||
@@ -129,15 +129,18 @@ function buildTagBadges(allTags, embyUserMap) {
|
||||
});
|
||||
}
|
||||
|
||||
// Track active dashboard clients: Map<username, { refreshRateMs, lastSeen }>
|
||||
// Track active dashboard clients.
|
||||
// SSE connections: registered on connect, removed on close — always accurate.
|
||||
// Legacy HTTP poll clients: pruned after CLIENT_STALE_MS of inactivity.
|
||||
const activeClients = new Map();
|
||||
const CLIENT_STALE_MS = 30000; // consider client gone after 30s of no requests
|
||||
const CLIENT_STALE_MS = 30000;
|
||||
|
||||
function getActiveClients() {
|
||||
const now = Date.now();
|
||||
// Prune stale clients
|
||||
for (const [key, client] of activeClients.entries()) {
|
||||
if (now - client.lastSeen > CLIENT_STALE_MS) activeClients.delete(key);
|
||||
if (client.type !== 'sse' && now - client.lastSeen > CLIENT_STALE_MS) {
|
||||
activeClients.delete(key);
|
||||
}
|
||||
}
|
||||
return Array.from(activeClients.values());
|
||||
}
|
||||
@@ -758,4 +761,269 @@ router.get('/cover-art', requireAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// SSE stream — pushes download data to the client on every poll cycle.
|
||||
// Uses the browser's built-in EventSource API (no library required).
|
||||
// Auth is enforced by requireAuth (emby_user cookie sent with the upgrade request).
|
||||
// No CSRF token needed — SSE is a GET request (safe method, no state change).
|
||||
router.get('/stream', requireAuth, async (req, res) => {
|
||||
const user = req.user;
|
||||
const username = user.name.toLowerCase();
|
||||
const showAll = !!user.isAdmin && req.query.showAll === 'true';
|
||||
|
||||
// SSE headers — disable buffering at every layer
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no'); // nginx: disable proxy buffering
|
||||
res.flushHeaders();
|
||||
|
||||
// Register as an active SSE client
|
||||
activeClients.set(username, { user: user.name, type: 'sse', connectedAt: Date.now(), lastSeen: Date.now() });
|
||||
console.log(`[SSE] Client connected: ${user.name}`);
|
||||
|
||||
// Helper: build and send the downloads payload for this user
|
||||
async function sendDownloads() {
|
||||
try {
|
||||
// On-demand: trigger a fresh poll if cache is stale and polling is disabled
|
||||
if (!POLLING_ENABLED && !cache.get('poll:sab-queue')) {
|
||||
await pollAllServices();
|
||||
}
|
||||
|
||||
const sabQueueData = cache.get('poll:sab-queue') || { slots: [] };
|
||||
const sabHistoryData = cache.get('poll:sab-history') || { slots: [] };
|
||||
const sonarrTagsResults = cache.get('poll:sonarr-tags') || [];
|
||||
const sonarrQueueData = cache.get('poll:sonarr-queue') || { records: [] };
|
||||
const sonarrHistoryData = cache.get('poll:sonarr-history') || { records: [] };
|
||||
const radarrQueueData = cache.get('poll:radarr-queue') || { records: [] };
|
||||
const radarrHistoryData = cache.get('poll:radarr-history') || { records: [] };
|
||||
const radarrTagsData = cache.get('poll:radarr-tags') || [];
|
||||
const qbittorrentTorrents = cache.get('poll:qbittorrent') || [];
|
||||
|
||||
const sabnzbdQueue = { data: { queue: sabQueueData } };
|
||||
const sabnzbdHistory = { data: { history: sabHistoryData } };
|
||||
const sonarrQueue = { data: sonarrQueueData };
|
||||
const sonarrHistory = { data: sonarrHistoryData };
|
||||
const radarrQueue = { data: radarrQueueData };
|
||||
const radarrHistory = { data: radarrHistoryData };
|
||||
const radarrTags = { data: radarrTagsData };
|
||||
|
||||
const seriesMap = new Map();
|
||||
for (const r of sonarrQueue.data.records) {
|
||||
if (r.series && r.seriesId) seriesMap.set(r.seriesId, r.series);
|
||||
}
|
||||
for (const r of sonarrHistory.data.records) {
|
||||
if (r.series && r.seriesId && !seriesMap.has(r.seriesId)) seriesMap.set(r.seriesId, r.series);
|
||||
}
|
||||
const moviesMap = new Map();
|
||||
for (const r of radarrQueue.data.records) {
|
||||
if (r.movie && r.movieId) moviesMap.set(r.movieId, r.movie);
|
||||
}
|
||||
for (const r of radarrHistory.data.records) {
|
||||
if (r.movie && r.movieId && !moviesMap.has(r.movieId)) moviesMap.set(r.movieId, r.movie);
|
||||
}
|
||||
|
||||
const sonarrTagMap = new Map(sonarrTagsResults.flatMap(t => t.data || []).map(t => [t.id, t.label]));
|
||||
const radarrTagMap = new Map(radarrTags.data.map(t => [t.id, t.label]));
|
||||
const embyUserMap = showAll ? await getEmbyUsers() : new Map();
|
||||
|
||||
// Inline the matching logic (same as /user-downloads)
|
||||
const userDownloads = [];
|
||||
const isAdmin = !!user.isAdmin;
|
||||
const usernameSanitized = sanitizeTagLabel(user.name);
|
||||
const queueStatus = sabnzbdQueue.data.queue ? sabnzbdQueue.data.queue.status : null;
|
||||
const queueSpeed = sabnzbdQueue.data.queue ? sabnzbdQueue.data.queue.speed : null;
|
||||
const queueKbpersec = sabnzbdQueue.data.queue ? sabnzbdQueue.data.queue.kbpersec : null;
|
||||
|
||||
function getSlotStatusAndSpeed(slot) {
|
||||
if (queueStatus === 'Paused') return { status: 'Paused', speed: '0' };
|
||||
return { status: slot.status || 'Unknown', speed: queueSpeed || queueKbpersec || '0' };
|
||||
}
|
||||
|
||||
// SABnzbd queue
|
||||
if (sabnzbdQueue.data.queue && sabnzbdQueue.data.queue.slots) {
|
||||
for (const slot of sabnzbdQueue.data.queue.slots) {
|
||||
const nzbName = slot.filename || slot.nzbname;
|
||||
if (!nzbName) continue;
|
||||
const slotState = getSlotStatusAndSpeed(slot);
|
||||
const nzbNameLower = nzbName.toLowerCase();
|
||||
|
||||
const sonarrMatch = sonarrQueue.data.records.find(r => {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
|
||||
});
|
||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
||||
if (series) {
|
||||
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
||||
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const dlObj = { type: 'series', title: nzbName, coverArt: getCoverArt(series), status: slotState.status, progress: slot.percentage, mb: slot.mb, mbmissing: slot.mbmissing, size: slot.size, speed: slotState.speed, eta: slot.timeleft, seriesName: series.title, episodeInfo: sonarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined };
|
||||
const issues = getImportIssues(sonarrMatch);
|
||||
if (issues) dlObj.importIssues = issues;
|
||||
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = getSonarrLink(series); }
|
||||
userDownloads.push(dlObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const radarrMatch = radarrQueue.data.records.find(r => {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
|
||||
});
|
||||
if (radarrMatch && radarrMatch.movieId) {
|
||||
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
||||
if (movie) {
|
||||
const allTags = extractAllTags(movie.tags, radarrTagMap);
|
||||
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const dlObj = { type: 'movie', title: nzbName, coverArt: getCoverArt(movie), status: slotState.status, progress: slot.percentage, mb: slot.mb, mbmissing: slot.mbmissing, size: slot.size, speed: slotState.speed, eta: slot.timeleft, movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined };
|
||||
const issues = getImportIssues(radarrMatch);
|
||||
if (issues) dlObj.importIssues = issues;
|
||||
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = movie.path || null; dlObj.arrLink = getRadarrLink(movie); }
|
||||
userDownloads.push(dlObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SABnzbd history
|
||||
if (sabnzbdHistory.data.history && sabnzbdHistory.data.history.slots) {
|
||||
for (const slot of sabnzbdHistory.data.history.slots) {
|
||||
const nzbName = slot.name || slot.nzb_name || slot.nzbname;
|
||||
if (!nzbName) continue;
|
||||
const nzbNameLower = nzbName.toLowerCase();
|
||||
|
||||
const sonarrMatch = sonarrHistory.data.records.find(r => {
|
||||
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
|
||||
});
|
||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
||||
if (series) {
|
||||
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
||||
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const dlObj = { type: 'series', title: nzbName, coverArt: getCoverArt(series), status: slot.status, size: slot.size, completedAt: slot.completed_time, seriesName: series.title, episodeInfo: sonarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined };
|
||||
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = getSonarrLink(series); }
|
||||
userDownloads.push(dlObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const radarrMatch = radarrHistory.data.records.find(r => {
|
||||
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
|
||||
});
|
||||
if (radarrMatch && radarrMatch.movieId) {
|
||||
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
||||
if (movie) {
|
||||
const allTags = extractAllTags(movie.tags, radarrTagMap);
|
||||
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const dlObj = { type: 'movie', title: nzbName, coverArt: getCoverArt(movie), status: slot.status, size: slot.size, completedAt: slot.completed_time, movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined };
|
||||
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = movie.path || null; dlObj.arrLink = getRadarrLink(movie); }
|
||||
userDownloads.push(dlObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// qBittorrent
|
||||
for (const torrent of qbittorrentTorrents) {
|
||||
const torrentName = torrent.name || '';
|
||||
if (!torrentName) continue;
|
||||
const torrentNameLower = torrentName.toLowerCase();
|
||||
|
||||
const sonarrMatch = sonarrQueue.data.records.find(r => { const rTitle = (r.title || r.sourceTitle || '').toLowerCase(); return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle)); });
|
||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
||||
if (series) {
|
||||
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
||||
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
Object.assign(download, { type: 'series', coverArt: getCoverArt(series), seriesName: series.title, episodeInfo: sonarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined });
|
||||
const issues = getImportIssues(sonarrMatch); if (issues) download.importIssues = issues;
|
||||
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; download.arrLink = getSonarrLink(series); }
|
||||
userDownloads.push(download); continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const radarrMatch = radarrQueue.data.records.find(r => { const rTitle = (r.title || r.sourceTitle || '').toLowerCase(); return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle)); });
|
||||
if (radarrMatch && radarrMatch.movieId) {
|
||||
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
||||
if (movie) {
|
||||
const allTags = extractAllTags(movie.tags, radarrTagMap);
|
||||
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
Object.assign(download, { type: 'movie', coverArt: getCoverArt(movie), movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined });
|
||||
const issues = getImportIssues(radarrMatch); if (issues) download.importIssues = issues;
|
||||
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = movie.path || null; download.arrLink = getRadarrLink(movie); }
|
||||
userDownloads.push(download); continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sonarrHistoryMatch = sonarrHistory.data.records.find(r => { const rTitle = (r.sourceTitle || r.title || '').toLowerCase(); return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle)); });
|
||||
if (sonarrHistoryMatch && sonarrHistoryMatch.seriesId) {
|
||||
const series = seriesMap.get(sonarrHistoryMatch.seriesId) || sonarrHistoryMatch.series;
|
||||
if (series) {
|
||||
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
||||
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
Object.assign(download, { type: 'series', coverArt: getCoverArt(series), seriesName: series.title, episodeInfo: sonarrHistoryMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined });
|
||||
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; download.arrLink = getSonarrLink(series); }
|
||||
userDownloads.push(download); continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const radarrHistoryMatch = radarrHistory.data.records.find(r => { const rTitle = (r.sourceTitle || r.title || '').toLowerCase(); return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle)); });
|
||||
if (radarrHistoryMatch && radarrHistoryMatch.movieId) {
|
||||
const movie = moviesMap.get(radarrHistoryMatch.movieId) || radarrHistoryMatch.movie;
|
||||
if (movie) {
|
||||
const allTags = extractAllTags(movie.tags, radarrTagMap);
|
||||
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
Object.assign(download, { type: 'movie', coverArt: getCoverArt(movie), movieName: movie.title, movieInfo: radarrHistoryMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined });
|
||||
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = movie.path || null; download.arrLink = getRadarrLink(movie); }
|
||||
userDownloads.push(download);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write SSE event
|
||||
res.write(`data: ${JSON.stringify({ user: user.name, isAdmin, downloads: userDownloads })}\n\n`);
|
||||
} catch (err) {
|
||||
console.error('[SSE] Error building payload:', sanitizeError(err));
|
||||
}
|
||||
}
|
||||
|
||||
// Send initial data immediately
|
||||
await sendDownloads();
|
||||
|
||||
// Subscribe to poll-complete notifications
|
||||
onPollComplete(sendDownloads);
|
||||
|
||||
// 25s heartbeat comment to keep the connection alive through proxies/load-balancers
|
||||
const heartbeat = setInterval(() => {
|
||||
try { res.write(': heartbeat\n\n'); } catch { /* ignore — cleanup below handles it */ }
|
||||
}, 25000);
|
||||
|
||||
// Cleanup on client disconnect
|
||||
req.on('close', () => {
|
||||
clearInterval(heartbeat);
|
||||
offPollComplete(sendDownloads);
|
||||
activeClients.delete(username);
|
||||
console.log(`[SSE] Client disconnected: ${user.name}`);
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -36,13 +36,17 @@ class MemoryCache {
|
||||
let totalSize = 0;
|
||||
|
||||
for (const [key, entry] of this.store.entries()) {
|
||||
const json = JSON.stringify(entry.value);
|
||||
// Maps must be converted before JSON.stringify (which renders them as "{}")
|
||||
const serializable = entry.value instanceof Map ? Object.fromEntries(entry.value) : entry.value;
|
||||
const json = JSON.stringify(serializable);
|
||||
const sizeBytes = Buffer.byteLength(json, 'utf8');
|
||||
totalSize += sizeBytes;
|
||||
const ttlRemaining = Math.max(0, entry.expiresAt - now);
|
||||
const expired = now > entry.expiresAt;
|
||||
let itemCount = null;
|
||||
if (Array.isArray(entry.value)) {
|
||||
if (entry.value instanceof Map) {
|
||||
itemCount = entry.value.size;
|
||||
} else if (Array.isArray(entry.value)) {
|
||||
itemCount = entry.value.length;
|
||||
} else if (entry.value && typeof entry.value === 'object') {
|
||||
if (Array.isArray(entry.value.records)) itemCount = entry.value.records.length;
|
||||
|
||||
+12
-1
@@ -16,6 +16,12 @@ const POLLING_ENABLED = POLL_INTERVAL > 0;
|
||||
let polling = false;
|
||||
let lastPollTimings = null;
|
||||
|
||||
// SSE subscribers: Set of () => void callbacks, each registered by an open /stream connection
|
||||
const pollSubscribers = new Set();
|
||||
|
||||
function onPollComplete(cb) { pollSubscribers.add(cb); }
|
||||
function offPollComplete(cb) { pollSubscribers.delete(cb); }
|
||||
|
||||
// Timed fetch helper: runs a fetch and records how long it took
|
||||
async function timed(label, fn) {
|
||||
const t0 = Date.now();
|
||||
@@ -184,6 +190,11 @@ async function pollAllServices() {
|
||||
|
||||
const elapsed = Date.now() - start;
|
||||
console.log(`[Poller] Poll complete in ${elapsed}ms`);
|
||||
|
||||
// Notify all SSE stream connections so they push fresh data immediately
|
||||
for (const cb of pollSubscribers) {
|
||||
try { cb(); } catch { /* subscriber already disconnected */ }
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Poller] Poll error:`, err.message);
|
||||
} finally {
|
||||
@@ -216,4 +227,4 @@ function getLastPollTimings() {
|
||||
return lastPollTimings;
|
||||
}
|
||||
|
||||
module.exports = { startPoller, stopPoller, pollAllServices, getLastPollTimings, POLL_INTERVAL, POLLING_ENABLED };
|
||||
module.exports = { startPoller, stopPoller, pollAllServices, getLastPollTimings, onPollComplete, offPollComplete, POLL_INTERVAL, POLLING_ENABLED };
|
||||
|
||||
+3
-3
@@ -31,10 +31,10 @@ export default defineConfig({
|
||||
// untested files; the security-critical files (auth, middleware, utils)
|
||||
// are well-covered by the 115 tests.
|
||||
thresholds: {
|
||||
lines: 25,
|
||||
lines: 22,
|
||||
functions: 12,
|
||||
branches: 12,
|
||||
statements: 25
|
||||
branches: 8,
|
||||
statements: 20
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user