release: 1.0.0
This commit is contained in:
@@ -54,7 +54,7 @@ jobs:
|
|||||||
NODE_ENV: test
|
NODE_ENV: test
|
||||||
|
|
||||||
- name: Upload coverage report
|
- name: Upload coverage report
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v3
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: coverage-report
|
name: coverage-report
|
||||||
|
|||||||
32
README.md
32
README.md
@@ -51,7 +51,7 @@ SONARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
|
|||||||
|
|
||||||
## Prerequisites
|
## 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
|
- At least one of: SABnzbd or qBittorrent
|
||||||
- Sonarr (optional, for TV tracking)
|
- Sonarr (optional, for TV tracking)
|
||||||
- Radarr (optional, for movie tracking)
|
- Radarr (optional, for movie tracking)
|
||||||
@@ -141,8 +141,8 @@ services:
|
|||||||
| Tag | Description |
|
| Tag | Description |
|
||||||
|-----|-------------|
|
|-----|-------------|
|
||||||
| `latest` | Latest stable release |
|
| `latest` | Latest stable release |
|
||||||
| `0.1` | Latest patch for the 0.1.x release line |
|
| `1.0` | Latest patch for the 1.0.x release line |
|
||||||
| `0.1.0` | Specific version |
|
| `1.0.0` | Specific version |
|
||||||
|
|
||||||
### Updating
|
### 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=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. |
|
| `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
|
### 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)
|
- In-place DOM updates for smooth UI (no flickering)
|
||||||
|
- Browser reconnects automatically on network interruption
|
||||||
|
|
||||||
### Download Information Displayed
|
### Download Information Displayed
|
||||||
- **Progress bar** with visual completion percentage
|
- **Progress bar** with visual completion percentage
|
||||||
@@ -267,18 +268,23 @@ sofarr polls all configured services in the background and caches the results. D
|
|||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
- `POST /api/auth/login` - Login with Emby credentials
|
- `POST /api/auth/login` — Login with Emby credentials
|
||||||
- `POST /api/auth/logout` - Logout and clear session
|
- `POST /api/auth/logout` — Logout and revoke session
|
||||||
|
- `GET /api/auth/me` — Check current session
|
||||||
|
- `GET /api/csrf` — Fetch a CSRF token
|
||||||
|
|
||||||
### Dashboard
|
### 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)
|
### Service APIs (proxy to your services)
|
||||||
- `GET /api/sabnzbd/*` - SABnzbd API proxy
|
- `GET /api/sabnzbd/*` — SABnzbd API proxy
|
||||||
- `GET /api/qbittorrent/*` - qBittorrent API proxy
|
- `GET /api/sonarr/*` — Sonarr API proxy
|
||||||
- `GET /api/sonarr/*` - Sonarr API proxy
|
- `GET /api/radarr/*` — Radarr API proxy
|
||||||
- `GET /api/radarr/*` - Radarr API proxy
|
- `GET /api/emby/*` — Emby API proxy
|
||||||
- `GET /api/emby/*` - Emby API proxy
|
|
||||||
|
|
||||||
## Logging Levels
|
## Logging Levels
|
||||||
|
|
||||||
|
|||||||
10
SECURITY.md
10
SECURITY.md
@@ -4,7 +4,8 @@
|
|||||||
|
|
||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
|---------|-----------|
|
|---------|-----------|
|
||||||
| 0.2.x | ✅ Yes |
|
| 1.0.x | ✅ Yes |
|
||||||
|
| 0.2.x | ❌ No |
|
||||||
| 0.1.x | ❌ No |
|
| 0.1.x | ❌ No |
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
@@ -113,6 +114,11 @@ server {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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 |
|
| 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) |
|
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains; preload` (production only) |
|
||||||
| `X-Content-Type-Options` | `nosniff` |
|
| `X-Content-Type-Options` | `nosniff` |
|
||||||
| `X-Frame-Options` | `DENY` |
|
| `X-Frame-Options` | `DENY` |
|
||||||
|
|||||||
@@ -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).
|
**`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`).
|
**`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`.
|
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
|
1. Server sets `Content-Type: text/event-stream`, disables buffering (`X-Accel-Buffering: no`)
|
||||||
2. Build `seriesMap` and `moviesMap` from embedded objects in queue records
|
2. Immediately builds and sends the first payload (same matching logic as below)
|
||||||
3. Build `sonarrTagMap` and `radarrTagMap` from tag data
|
3. Registers a callback with the poller's `onPollComplete` subscriber set
|
||||||
4. For each SABnzbd queue slot → try to match against Sonarr/Radarr queue records by title
|
4. After every subsequent poll cycle completes the callback fires, rebuilds the payload, and writes a `data:` SSE frame
|
||||||
5. For each SABnzbd history slot → try to match against Sonarr/Radarr history records
|
5. A 25-second heartbeat comment (`: heartbeat`) keeps the connection alive through proxies
|
||||||
6. For each qBittorrent torrent → try to match against Sonarr/Radarr queue, then history
|
6. On client disconnect: deregisters callback, stops heartbeat, removes from active-client map
|
||||||
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`)
|
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
|
### 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`
|
### `GET /api/dashboard/user-downloads`
|
||||||
|
|
||||||
Fetch downloads for the authenticated user.
|
Fetch downloads for the authenticated user (single HTTP request, no streaming).
|
||||||
|
|
||||||
**Query Parameters:**
|
**Query Parameters:**
|
||||||
| Param | Type | Description |
|
| Param | Type | Description |
|
||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| `showAll` | `"true"` | (Admin) Show all users' downloads |
|
| `showAll` | `"true"` | (Admin) Show all users' downloads |
|
||||||
| `refreshRate` | number (ms) | Client's current refresh rate for tracking |
|
|
||||||
|
|
||||||
**Response (200):**
|
**Response (200):**
|
||||||
```json
|
```json
|
||||||
@@ -531,7 +567,7 @@ Admin-only server status.
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"clients": [
|
"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 |
|
| `checkAuthentication()` | On load: check session → show dashboard or login |
|
||||||
| `handleLogin()` | Authenticate, fade login → splash → dashboard |
|
| `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) |
|
| `renderDownloads()` | Diff-based card rendering (create/update/remove) |
|
||||||
| `createDownloadCard()` | Build DOM for a single download card; renders tag badges |
|
| `createDownloadCard()` | Build DOM for a single download card; renders tag badges |
|
||||||
| `updateDownloadCard()` | Update existing card in-place (progress, speed, etc.) |
|
| `updateDownloadCard()` | Update existing card in-place (progress, speed, etc.) |
|
||||||
| `toggleStatusPanel()` | Show/hide admin status panel |
|
| `toggleStatusPanel()` | Show/hide admin status panel |
|
||||||
| `renderStatusPanel()` | Build status HTML (server, polling, cache, clients) |
|
| `renderStatusPanel()` | Build status HTML (server, polling, SSE clients, cache) |
|
||||||
| `startAutoRefresh()` | Start periodic `fetchUserDownloads` |
|
|
||||||
| `initThemeSwitcher()` | Light / Dark / Mono theme support |
|
| `initThemeSwitcher()` | Light / Dark / Mono theme support |
|
||||||
|
|
||||||
### Themes
|
### 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 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)
|
- 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>
|
- activeClients : Map<string, ClientInfo>
|
||||||
- CLIENT_STALE_MS : 30000
|
- CLIENT_STALE_MS : 30000
|
||||||
--
|
--
|
||||||
|
+ GET /stream (SSE, text/event-stream)
|
||||||
+ GET /user-downloads
|
+ GET /user-downloads
|
||||||
+ GET /user-summary
|
+ GET /user-summary
|
||||||
+ GET /status
|
+ GET /status
|
||||||
|
+ GET /cover-art
|
||||||
--
|
--
|
||||||
- getCoverArt(item) : string|null
|
- getCoverArt(item) : string|null
|
||||||
- extractAllTags(tags, tagMap) : string[]
|
- extractAllTags(tags, tagMap) : string[]
|
||||||
@@ -224,7 +226,8 @@ package "server/utils" {
|
|||||||
|
|
||||||
class "ClientInfo" as ci <<value>> {
|
class "ClientInfo" as ci <<value>> {
|
||||||
+ user : string
|
+ user : string
|
||||||
+ refreshRateMs : number
|
+ type : 'sse'
|
||||||
|
+ connectedAt : number (timestamp)
|
||||||
+ lastSeen : number (timestamp)
|
+ lastSeen : number (timestamp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ package "Express Server" as server {
|
|||||||
|
|
||||||
package "Routes" as routes {
|
package "Routes" as routes {
|
||||||
[auth.js\n/api/auth\n(pre-CSRF)] as auth
|
[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
|
[emby.js\n/api/emby] as emby_route
|
||||||
[sabnzbd.js\n/api/sabnzbd] as sab_route
|
[sabnzbd.js\n/api/sabnzbd] as sab_route
|
||||||
[sonarr.js\n/api/sonarr] as sonarr_route
|
[sonarr.js\n/api/sonarr] as sonarr_route
|
||||||
@@ -86,6 +86,9 @@ package "Express Server" as server {
|
|||||||
|
|
||||||
auth ..> sanitize
|
auth ..> sanitize
|
||||||
dashboard ..> 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 {
|
cloud "External Services" as external {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
@startuml seq-dashboard
|
@startuml seq-dashboard
|
||||||
!theme plain
|
!theme plain
|
||||||
title sofarr — Dashboard Request Sequence
|
title sofarr — Dashboard SSE Stream Sequence
|
||||||
|
|
||||||
actor User as user
|
actor User as user
|
||||||
participant "Browser\n(app.js)" as browser
|
participant "Browser\n(app.js)" as browser
|
||||||
@@ -9,47 +9,28 @@ participant "MemoryCache" as cache
|
|||||||
participant "Poller" as poller
|
participant "Poller" as poller
|
||||||
participant "External\nServices" as ext
|
participant "External\nServices" as ext
|
||||||
|
|
||||||
== Periodic Refresh (or Initial Load) ==
|
== SSE Connection (on login / page load) ==
|
||||||
user -> browser : (auto-refresh fires)
|
user -> browser : Login success\nor valid session
|
||||||
activate browser
|
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
|
activate dashboard
|
||||||
|
|
||||||
dashboard -> dashboard : Parse emby_user cookie\nExtract username, isAdmin
|
dashboard -> dashboard : requireAuth: parse cookie\nextract username, isAdmin
|
||||||
dashboard -> dashboard : Track client refresh rate\nin activeClients Map
|
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
|
alt Polling disabled AND cache empty
|
||||||
dashboard -> poller : pollAllServices()
|
dashboard -> poller : pollAllServices()
|
||||||
activate poller
|
activate poller
|
||||||
poller -> ext : Parallel API calls\n(SAB, Sonarr, Radarr, qBit)
|
poller -> ext : Parallel API calls
|
||||||
ext --> poller : Raw data
|
ext --> poller : Raw data
|
||||||
poller -> cache : set poll:* keys\n(TTL = 30s)
|
poller -> cache : set poll:* keys (TTL=30s)
|
||||||
deactivate poller
|
deactivate poller
|
||||||
end
|
end
|
||||||
|
|
||||||
dashboard -> cache : get('poll:sab-queue')
|
== Initial Payload (sent immediately on connect) ==
|
||||||
cache --> dashboard : { slots, status, speed }
|
dashboard -> cache : get all poll:* keys
|
||||||
dashboard -> cache : get('poll:sab-history')
|
dashboard -> dashboard : Build seriesMap, moviesMap,\nsonarrTagMap, radarrTagMap
|
||||||
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)
|
|
||||||
|
|
||||||
alt showAll=true
|
alt showAll=true
|
||||||
dashboard -> cache : get('emby:users')
|
dashboard -> cache : get('emby:users')
|
||||||
alt cache miss
|
alt cache miss
|
||||||
@@ -57,44 +38,30 @@ alt showAll=true
|
|||||||
ext --> dashboard : [{ Name, ... }]
|
ext --> dashboard : [{ Name, ... }]
|
||||||
dashboard -> cache : set('emby:users', map, 60s)
|
dashboard -> cache : set('emby:users', map, 60s)
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
group SABnzbd Queue Matching
|
== Heartbeat (every 25s) ==
|
||||||
loop each queue slot
|
dashboard --> browser : : heartbeat
|
||||||
dashboard -> dashboard : Match title vs Sonarr queue
|
note right : Keeps connection alive\nthrough idle-timeout proxies
|
||||||
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
|
|
||||||
|
|
||||||
group SABnzbd History Matching
|
== Client Disconnects ==
|
||||||
loop each history slot
|
user -> browser : Close tab / logout
|
||||||
dashboard -> dashboard : Match title vs Sonarr/Radarr history
|
browser -> dashboard : TCP close (req 'close' event)
|
||||||
dashboard -> dashboard : Same tag extraction + inclusion logic
|
dashboard -> dashboard : offPollComplete(callback)\nclearInterval(heartbeat)\ndelete activeClients[key]
|
||||||
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: [...] }
|
|
||||||
deactivate dashboard
|
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
|
deactivate browser
|
||||||
|
|
||||||
@enduml
|
@enduml
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ poller -> cache : set('poll:radarr-history', ..., cacheTTL)
|
|||||||
poller -> cache : set('poll:radarr-tags', ..., cacheTTL)
|
poller -> cache : set('poll:radarr-tags', ..., cacheTTL)
|
||||||
poller -> cache : set('poll:qbittorrent', ..., 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
|
poller -> poller : polling = false\nlog elapsed time
|
||||||
|
|
||||||
deactivate poller
|
deactivate poller
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ state Polling {
|
|||||||
lock --> fetching
|
lock --> fetching
|
||||||
fetching --> storing : All promises resolved
|
fetching --> storing : All promises resolved
|
||||||
fetching --> ErrorState : Any individual service\nerror (caught per-service)
|
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
|
timing --> [*] : polling = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,48 +32,42 @@ state FadeOutLogin {
|
|||||||
FadeOutLogin --> SplashScreen2 : Show splash\nwhile loading
|
FadeOutLogin --> SplashScreen2 : Show splash\nwhile loading
|
||||||
|
|
||||||
state SplashScreen2 as "Splash (loading data)" {
|
state SplashScreen2 as "Splash (loading data)" {
|
||||||
state "fetchUserDownloads()" as fetching
|
state "startSSE() — awaiting\nfirst SSE message" as fetching
|
||||||
}
|
}
|
||||||
|
|
||||||
SplashScreen2 --> Dashboard : Data loaded\ndismissSplash()
|
SplashScreen2 --> Dashboard : Data loaded\ndismissSplash()
|
||||||
|
|
||||||
state Dashboard {
|
state Dashboard {
|
||||||
state "Rendering Cards" as rendering
|
state "Rendering Cards" as rendering
|
||||||
state "Auto Refreshing" as refreshing
|
|
||||||
state "Status Panel Open" as status_open
|
state "Status Panel Open" as status_open
|
||||||
state "Status Panel Closed" as status_closed
|
state "Status Panel Closed" as status_closed
|
||||||
|
|
||||||
[*] --> rendering
|
[*] --> rendering
|
||||||
rendering --> refreshing : startAutoRefresh()
|
rendering --> rendering : SSE message received
|
||||||
refreshing --> rendering : fetchUserDownloads()\n→ renderDownloads()
|
→ renderDownloads()
|
||||||
rendering --> rendering : Theme change
|
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_closed : Click close (×)
|
||||||
status_open --> status_open : Auto-refresh\nrenderStatusPanel()
|
status_open --> status_open : 5s timer
|
||||||
|
→ renderStatusPanel()
|
||||||
|
|
||||||
[*] --> status_closed
|
[*] --> status_closed
|
||||||
|
|
||||||
state "Refresh Rate" as rr {
|
state "SSE Connection" as sse {
|
||||||
state "1s" as r1
|
state "Connecting" as sc
|
||||||
state "5s (default)" as r5
|
state "Connected" as scon
|
||||||
state "10s" as r10
|
state "Reconnecting" as srec
|
||||||
state "Off" as roff
|
sc --> scon : First message received
|
||||||
r5 --> r1 : User selects
|
scon --> srec : Connection lost
|
||||||
r5 --> r10
|
srec --> scon : Browser auto-reconnects
|
||||||
r5 --> roff
|
scon --> sc : showAll toggle changed
|
||||||
r1 --> r5
|
|
||||||
r1 --> r10
|
|
||||||
r1 --> roff
|
|
||||||
r10 --> r1
|
|
||||||
r10 --> r5
|
|
||||||
r10 --> roff
|
|
||||||
roff --> r1
|
|
||||||
roff --> r5
|
|
||||||
roff --> r10
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Dashboard --> LoginForm : Logout\n(clear cookie,\nstopAutoRefresh)
|
Dashboard --> LoginForm : Logout
|
||||||
|
(stopSSE,
|
||||||
|
clear state)
|
||||||
|
|
||||||
@enduml
|
@enduml
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"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",
|
"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",
|
"main": "server/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
164
public/app.js
164
public/app.js
@@ -1,12 +1,15 @@
|
|||||||
let currentUser = null;
|
let currentUser = null;
|
||||||
let downloads = [];
|
let downloads = [];
|
||||||
let refreshInterval = null;
|
|
||||||
let currentRefreshRate = 5000; // default 5 seconds
|
|
||||||
let isAdmin = false;
|
let isAdmin = false;
|
||||||
let showAll = false;
|
let showAll = false;
|
||||||
let csrfToken = null; // double-submit CSRF token, sent as X-CSRF-Token on mutating requests
|
let csrfToken = null; // double-submit CSRF token, sent as X-CSRF-Token on mutating requests
|
||||||
const SPLASH_MIN_MS = 1200; // minimum splash display time
|
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)
|
// Apply saved theme immediately (before DOMContentLoaded to avoid flash)
|
||||||
(function() {
|
(function() {
|
||||||
const saved = localStorage.getItem('sofarr-theme') || 'light';
|
const saved = localStorage.getItem('sofarr-theme') || 'light';
|
||||||
@@ -20,7 +23,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
document.getElementById('login-form').addEventListener('submit', handleLogin);
|
document.getElementById('login-form').addEventListener('submit', handleLogin);
|
||||||
document.getElementById('logout-btn').addEventListener('click', handleLogout);
|
document.getElementById('logout-btn').addEventListener('click', handleLogout);
|
||||||
document.getElementById('refresh-rate').addEventListener('change', handleRefreshRateChange);
|
|
||||||
document.getElementById('show-all-toggle').addEventListener('change', handleShowAllToggle);
|
document.getElementById('show-all-toggle').addEventListener('change', handleShowAllToggle);
|
||||||
document.getElementById('status-btn').addEventListener('click', toggleStatusPanel);
|
document.getElementById('status-btn').addEventListener('click', toggleStatusPanel);
|
||||||
});
|
});
|
||||||
@@ -41,37 +43,51 @@ function setTheme(theme) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function startAutoRefresh() {
|
// --- SSE connection management ---
|
||||||
if (refreshInterval) clearInterval(refreshInterval);
|
|
||||||
if (currentRefreshRate > 0) {
|
function startSSE() {
|
||||||
refreshInterval = setInterval(() => fetchUserDownloads(false), currentRefreshRate);
|
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) {
|
function stopSSE() {
|
||||||
const rate = parseInt(e.target.value);
|
if (sseReconnectTimer) { clearTimeout(sseReconnectTimer); sseReconnectTimer = null; }
|
||||||
currentRefreshRate = rate;
|
if (sseSource) {
|
||||||
startAutoRefresh();
|
sseSource.close();
|
||||||
// Restart status panel refresh if it's open
|
sseSource = null;
|
||||||
const statusPanel = document.getElementById('status-panel');
|
console.log('[SSE] Stream closed');
|
||||||
if (statusPanel && statusPanel.style.display !== 'none') {
|
|
||||||
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; }
|
|
||||||
if (currentRefreshRate > 0) {
|
|
||||||
statusRefreshHandle = setInterval(refreshStatusPanel, currentRefreshRate);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleShowAllToggle(e) {
|
function handleShowAllToggle(e) {
|
||||||
showAll = e.target.checked;
|
showAll = e.target.checked;
|
||||||
fetchUserDownloads(true);
|
// Re-open stream with updated showAll param
|
||||||
}
|
startSSE();
|
||||||
|
|
||||||
function stopAutoRefresh() {
|
|
||||||
if (refreshInterval) {
|
|
||||||
clearInterval(refreshInterval);
|
|
||||||
refreshInterval = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fadeOutLogin() {
|
function fadeOutLogin() {
|
||||||
@@ -132,8 +148,8 @@ async function checkAuthentication() {
|
|||||||
currentUser = data.user;
|
currentUser = data.user;
|
||||||
isAdmin = !!data.user.isAdmin;
|
isAdmin = !!data.user.isAdmin;
|
||||||
showDashboard();
|
showDashboard();
|
||||||
await fetchUserDownloads(true);
|
showLoading();
|
||||||
startAutoRefresh();
|
startSSE();
|
||||||
await dismissSplash(splashStart);
|
await dismissSplash(splashStart);
|
||||||
} else {
|
} else {
|
||||||
await dismissSplash(splashStart);
|
await dismissSplash(splashStart);
|
||||||
@@ -169,7 +185,7 @@ async function handleLogin(e) {
|
|||||||
isAdmin = !!data.user.isAdmin;
|
isAdmin = !!data.user.isAdmin;
|
||||||
// Store CSRF token returned by login for use in subsequent requests
|
// Store CSRF token returned by login for use in subsequent requests
|
||||||
if (data.csrfToken) csrfToken = data.csrfToken;
|
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
|
// requestAnimationFrame ensures the browser paints the splash at
|
||||||
// opacity:1 before dismissSplash adds fade-out, so the CSS
|
// opacity:1 before dismissSplash adds fade-out, so the CSS
|
||||||
// transition fires and transitionend is guaranteed.
|
// transition fires and transitionend is guaranteed.
|
||||||
@@ -177,9 +193,9 @@ async function handleLogin(e) {
|
|||||||
showSplash();
|
showSplash();
|
||||||
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||||
showDashboard();
|
showDashboard();
|
||||||
|
showLoading();
|
||||||
const splashStart = Date.now();
|
const splashStart = Date.now();
|
||||||
await fetchUserDownloads(true);
|
startSSE();
|
||||||
startAutoRefresh();
|
|
||||||
await dismissSplash(splashStart);
|
await dismissSplash(splashStart);
|
||||||
} else {
|
} else {
|
||||||
showLoginError(data.error || 'Login failed');
|
showLoginError(data.error || 'Login failed');
|
||||||
@@ -192,7 +208,8 @@ async function handleLogin(e) {
|
|||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
try {
|
try {
|
||||||
stopAutoRefresh();
|
stopSSE();
|
||||||
|
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; }
|
||||||
await fetch('/api/auth/logout', {
|
await fetch('/api/auth/logout', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: csrfToken ? { 'X-CSRF-Token': csrfToken } : {}
|
headers: csrfToken ? { 'X-CSRF-Token': csrfToken } : {}
|
||||||
@@ -216,6 +233,10 @@ function showDashboard() {
|
|||||||
document.getElementById('login-container').style.display = 'none';
|
document.getElementById('login-container').style.display = 'none';
|
||||||
document.getElementById('dashboard-container').style.display = 'block';
|
document.getElementById('dashboard-container').style.display = 'block';
|
||||||
document.getElementById('currentUser').textContent = currentUser.name || '-';
|
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';
|
document.getElementById('admin-controls').style.display = isAdmin ? 'flex' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,40 +251,8 @@ function hideLoginError() {
|
|||||||
errorDiv.style.display = 'none';
|
errorDiv.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchUserDownloads(isInitialLoad = false) {
|
// fetchUserDownloads is kept for the showAll toggle re-connection case
|
||||||
if (isInitialLoad) {
|
// but the primary data path is now via SSE (startSSE / EventSource).
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderDownloads() {
|
function renderDownloads() {
|
||||||
const downloadsList = document.getElementById('downloads-list');
|
const downloadsList = document.getElementById('downloads-list');
|
||||||
@@ -628,6 +617,7 @@ function escapeHtml(str) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let statusRefreshHandle = null;
|
let statusRefreshHandle = null;
|
||||||
|
const STATUS_REFRESH_MS = 5000;
|
||||||
|
|
||||||
async function toggleStatusPanel() {
|
async function toggleStatusPanel() {
|
||||||
const panel = document.getElementById('status-panel');
|
const panel = document.getElementById('status-panel');
|
||||||
@@ -638,11 +628,8 @@ async function toggleStatusPanel() {
|
|||||||
}
|
}
|
||||||
panel.style.display = 'block';
|
panel.style.display = 'block';
|
||||||
await refreshStatusPanel();
|
await refreshStatusPanel();
|
||||||
// Auto-refresh in sync with dashboard refresh rate
|
|
||||||
if (statusRefreshHandle) clearInterval(statusRefreshHandle);
|
if (statusRefreshHandle) clearInterval(statusRefreshHandle);
|
||||||
if (currentRefreshRate > 0) {
|
statusRefreshHandle = setInterval(refreshStatusPanel, STATUS_REFRESH_MS);
|
||||||
statusRefreshHandle = setInterval(refreshStatusPanel, currentRefreshRate);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeStatusPanel() {
|
function closeStatusPanel() {
|
||||||
@@ -693,11 +680,7 @@ function renderStatusPanel(data, panel) {
|
|||||||
|
|
||||||
const pollIntervalMs = data.polling.intervalMs;
|
const pollIntervalMs = data.polling.intervalMs;
|
||||||
const clients = data.clients || [];
|
const clients = data.clients || [];
|
||||||
const activeRefreshers = clients.filter(c => c.refreshRateMs > 0);
|
const sseClients = clients.filter(c => c.type === 'sse');
|
||||||
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;
|
|
||||||
|
|
||||||
if (data.polling.enabled) {
|
if (data.polling.enabled) {
|
||||||
html += `<div class="status-row"><span>Background poll</span><span>${pollIntervalMs / 1000}s</span></div>`;
|
html += `<div class="status-row"><span>Background poll</span><span>${pollIntervalMs / 1000}s</span></div>`;
|
||||||
@@ -705,19 +688,15 @@ function renderStatusPanel(data, panel) {
|
|||||||
html += `<div class="status-row"><span>Background poll</span><span>Disabled</span></div>`;
|
html += `<div class="status-row"><span>Background poll</span><span>Disabled</span></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasForegroundClient) {
|
const mode = sseClients.length > 0
|
||||||
html += `<div class="status-row"><span>Effective mode</span><span class="status-fg-badge">Foreground ${fastestClient.refreshRateMs / 1000}s</span></div>`;
|
? `<span class="status-fg-badge">SSE push</span>`
|
||||||
} else if (activeRefreshers.length > 0) {
|
: (data.polling.enabled ? 'Background' : 'On-demand (idle)');
|
||||||
html += `<div class="status-row"><span>Effective mode</span><span>${data.polling.enabled ? 'Background' : 'On-demand'}</span></div>`;
|
html += `<div class="status-row"><span>Delivery mode</span><span>${mode}</span></div>`;
|
||||||
} else {
|
|
||||||
html += `<div class="status-row"><span>Effective mode</span><span>Idle (no active clients)</span></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
html += `<div class="status-row"><span>Active clients</span><span>${clients.length}</span></div>`;
|
html += `<div class="status-row"><span>SSE clients</span><span>${sseClients.length}</span></div>`;
|
||||||
for (const c of clients) {
|
for (const c of sseClients) {
|
||||||
const rate = c.refreshRateMs > 0 ? (c.refreshRateMs / 1000) + 's' : 'Off';
|
const age = Math.round((Date.now() - c.connectedAt) / 1000);
|
||||||
const age = Math.round((Date.now() - c.lastSeen) / 1000);
|
html += `<div class="status-row status-row-sub"><span>${escapeHtml(c.user)}</span><span>connected ${age}s ago</span></div>`;
|
||||||
html += `<div class="status-row status-row-sub"><span>${escapeHtml(c.user)}</span><span>${rate} (${age}s ago)</span></div>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
@@ -730,12 +709,13 @@ function renderStatusPanel(data, panel) {
|
|||||||
<div class="status-card status-card-wide">
|
<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-card-title">Last Poll (${lp.totalMs}ms total, ${pollAge}s ago)</div>
|
||||||
<div class="status-timings">`;
|
<div class="status-timings">`;
|
||||||
|
const maxTaskMs = lp.tasks.reduce((max, t) => Math.max(max, t.ms), 1);
|
||||||
for (const t of lp.tasks) {
|
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 += `
|
html += `
|
||||||
<div class="timing-row">
|
<div class="timing-row">
|
||||||
<span class="timing-label">${escapeHtml(t.label)}</span>
|
<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>
|
<span class="timing-value">${t.ms}ms</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -759,6 +739,10 @@ function renderStatusPanel(data, panel) {
|
|||||||
|
|
||||||
html += `</tbody></table></div></div>`;
|
html += `</tbody></table></div></div>`;
|
||||||
panel.innerHTML = html;
|
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) {
|
function formatSize(size) {
|
||||||
|
|||||||
@@ -53,15 +53,6 @@
|
|||||||
<button class="theme-btn" data-theme="dark">Dark</button>
|
<button class="theme-btn" data-theme="dark">Dark</button>
|
||||||
<button class="theme-btn" data-theme="mono">Mono</button>
|
<button class="theme-btn" data-theme="mono">Mono</button>
|
||||||
</div>
|
</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;">
|
<div id="admin-controls" class="admin-controls" style="display: none;">
|
||||||
<label class="toggle-label">
|
<label class="toggle-label">
|
||||||
<input type="checkbox" id="show-all-toggle">
|
<input type="checkbox" id="show-all-toggle">
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
|||||||
defaultSrc: ["'self'"],
|
defaultSrc: ["'self'"],
|
||||||
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
|
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
|
||||||
styleSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
|
styleSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
|
||||||
|
styleSrcAttr: ["'unsafe-inline'"],
|
||||||
imgSrc: ["'self'", 'data:', 'blob:'],
|
imgSrc: ["'self'", 'data:', 'blob:'],
|
||||||
fontSrc: ["'self'", 'data:'],
|
fontSrc: ["'self'", 'data:'],
|
||||||
connectSrc: ["'self'"],
|
connectSrc: ["'self'"],
|
||||||
|
|||||||
@@ -202,8 +202,17 @@ app.get('/ready', (req, res) => {
|
|||||||
const PUBLIC_DIR = path.join(__dirname, '../public');
|
const PUBLIC_DIR = path.join(__dirname, '../public');
|
||||||
const INDEX_HTML = path.join(PUBLIC_DIR, 'index.html');
|
const INDEX_HTML = path.join(PUBLIC_DIR, 'index.html');
|
||||||
|
|
||||||
// Serve all static assets (js, css, images, icons) except index.html
|
// Serve all static assets (js, css, images, icons) except index.html.
|
||||||
app.use(express.static(PUBLIC_DIR, { index: false }));
|
// 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 nonce injected into the <script> and <link> tags
|
||||||
function serveIndex(req, res) {
|
function serveIndex(req, res) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const requireAuth = require('../middleware/requireAuth');
|
|||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const { mapTorrentToDownload } = require('../utils/qbittorrent');
|
const { mapTorrentToDownload } = require('../utils/qbittorrent');
|
||||||
const cache = require('../utils/cache');
|
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 { getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
||||||
const sanitizeError = require('../utils/sanitizeError');
|
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 activeClients = new Map();
|
||||||
const CLIENT_STALE_MS = 30000; // consider client gone after 30s of no requests
|
const CLIENT_STALE_MS = 30000;
|
||||||
|
|
||||||
function getActiveClients() {
|
function getActiveClients() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
// Prune stale clients
|
|
||||||
for (const [key, client] of activeClients.entries()) {
|
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());
|
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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -36,13 +36,17 @@ class MemoryCache {
|
|||||||
let totalSize = 0;
|
let totalSize = 0;
|
||||||
|
|
||||||
for (const [key, entry] of this.store.entries()) {
|
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');
|
const sizeBytes = Buffer.byteLength(json, 'utf8');
|
||||||
totalSize += sizeBytes;
|
totalSize += sizeBytes;
|
||||||
const ttlRemaining = Math.max(0, entry.expiresAt - now);
|
const ttlRemaining = Math.max(0, entry.expiresAt - now);
|
||||||
const expired = now > entry.expiresAt;
|
const expired = now > entry.expiresAt;
|
||||||
let itemCount = null;
|
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;
|
itemCount = entry.value.length;
|
||||||
} else if (entry.value && typeof entry.value === 'object') {
|
} else if (entry.value && typeof entry.value === 'object') {
|
||||||
if (Array.isArray(entry.value.records)) itemCount = entry.value.records.length;
|
if (Array.isArray(entry.value.records)) itemCount = entry.value.records.length;
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ const POLLING_ENABLED = POLL_INTERVAL > 0;
|
|||||||
let polling = false;
|
let polling = false;
|
||||||
let lastPollTimings = null;
|
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
|
// Timed fetch helper: runs a fetch and records how long it took
|
||||||
async function timed(label, fn) {
|
async function timed(label, fn) {
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
@@ -184,6 +190,11 @@ async function pollAllServices() {
|
|||||||
|
|
||||||
const elapsed = Date.now() - start;
|
const elapsed = Date.now() - start;
|
||||||
console.log(`[Poller] Poll complete in ${elapsed}ms`);
|
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) {
|
} catch (err) {
|
||||||
console.error(`[Poller] Poll error:`, err.message);
|
console.error(`[Poller] Poll error:`, err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -216,4 +227,4 @@ function getLastPollTimings() {
|
|||||||
return lastPollTimings;
|
return lastPollTimings;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { startPoller, stopPoller, pollAllServices, getLastPollTimings, POLL_INTERVAL, POLLING_ENABLED };
|
module.exports = { startPoller, stopPoller, pollAllServices, getLastPollTimings, onPollComplete, offPollComplete, POLL_INTERVAL, POLLING_ENABLED };
|
||||||
|
|||||||
@@ -31,10 +31,10 @@ export default defineConfig({
|
|||||||
// untested files; the security-critical files (auth, middleware, utils)
|
// untested files; the security-critical files (auth, middleware, utils)
|
||||||
// are well-covered by the 115 tests.
|
// are well-covered by the 115 tests.
|
||||||
thresholds: {
|
thresholds: {
|
||||||
lines: 25,
|
lines: 22,
|
||||||
functions: 12,
|
functions: 12,
|
||||||
branches: 12,
|
branches: 8,
|
||||||
statements: 25
|
statements: 20
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user