docs: final 1.0.0 documentation pass
README.md: - Node prerequisite: v12+ → v22+ - Real-Time Updates: describe SSE push, remove polling/refresh-selector wording - On-demand mode: update for SSE connect triggering poll - API Endpoints: add /stream, /me, /csrf, /user-summary, /status, /cover-art - Remove stale /api/qbittorrent proxy entry - Docker tags: update to 1.0.x SECURITY.md: - Supported versions: add 1.0.x, retire 0.2.x - CSP header: add style-src-attr 'unsafe-inline' - Nginx example: add proxy_buffering off / proxy_read_timeout for SSE Diagrams: - seq-dashboard.puml: rewrite as SSE stream sequence (connect, initial payload, pushed updates, heartbeat, disconnect) - seq-polling.puml: add SSE subscriber notification step after cache population - state-ui.puml: replace Refresh Rate sub-state with SSE Connection state machine; update splash loading and logout transitions - state-poller.puml: add Notifying SSE subscribers step in Polling state package.json: bump to 1.0.0
This commit is contained in:
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` |
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
Reference in New Issue
Block a user