diff --git a/README.md b/README.md index da76491..3154fa6 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ SONARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}] ## Prerequisites -- **Docker** (recommended), or Node.js (v12+) for manual installation +- **Docker** (recommended), or Node.js (v22+) for manual installation - At least one of: SABnzbd or qBittorrent - Sonarr (optional, for TV tracking) - Radarr (optional, for movie tracking) @@ -141,8 +141,8 @@ services: | Tag | Description | |-----|-------------| | `latest` | Latest stable release | -| `0.1` | Latest patch for the 0.1.x release line | -| `0.1.0` | Specific version | +| `1.0` | Latest patch for the 1.0.x release line | +| `1.0.0` | Specific version | ### Updating @@ -245,11 +245,12 @@ sofarr polls all configured services in the background and caches the results. D | `POLL_INTERVAL=10000` | Poll every 10 seconds. | | `POLL_INTERVAL=0` / `off` / `disabled` | No background polling. Data fetched on-demand when a user opens the dashboard, then cached for 30 seconds. | -**On-demand mode** is useful for low-resource setups. When one user's browser refreshes, the fetched data is cached and served to all other users until it expires. A user with a faster refresh rate effectively keeps the cache warm for everyone. +**On-demand mode** is useful for low-resource setups. When one user's browser opens the SSE stream it triggers a fresh poll; the fetched data is cached and served to all other connected clients until the next poll. ### Real-Time Updates -- Auto-refresh every 5 seconds (configurable: 1s, 5s, 10s, or off) +- **Server-Sent Events (SSE)** — the server pushes updates to the browser immediately after each poll cycle. No client-side timer; no wasted requests. - In-place DOM updates for smooth UI (no flickering) +- Browser reconnects automatically on network interruption ### Download Information Displayed - **Progress bar** with visual completion percentage @@ -267,18 +268,23 @@ sofarr polls all configured services in the background and caches the results. D ## API Endpoints ### Authentication -- `POST /api/auth/login` - Login with Emby credentials -- `POST /api/auth/logout` - Logout and clear session +- `POST /api/auth/login` — Login with Emby credentials +- `POST /api/auth/logout` — Logout and revoke session +- `GET /api/auth/me` — Check current session +- `GET /api/csrf` — Fetch a CSRF token ### Dashboard -- `GET /api/dashboard/downloads` - Get all downloads for authenticated user +- `GET /api/dashboard/stream` — **SSE stream**: pushes `{ user, isAdmin, downloads }` on every poll cycle +- `GET /api/dashboard/user-downloads` — Single-request download fetch (no streaming) +- `GET /api/dashboard/user-summary` — Per-user download counts (admin) +- `GET /api/dashboard/status` — Server / polling / cache status (admin) +- `GET /api/dashboard/cover-art` — Proxied cover art image ### Service APIs (proxy to your services) -- `GET /api/sabnzbd/*` - SABnzbd API proxy -- `GET /api/qbittorrent/*` - qBittorrent API proxy -- `GET /api/sonarr/*` - Sonarr API proxy -- `GET /api/radarr/*` - Radarr API proxy -- `GET /api/emby/*` - Emby API proxy +- `GET /api/sabnzbd/*` — SABnzbd API proxy +- `GET /api/sonarr/*` — Sonarr API proxy +- `GET /api/radarr/*` — Radarr API proxy +- `GET /api/emby/*` — Emby API proxy ## Logging Levels diff --git a/SECURITY.md b/SECURITY.md index 406188f..448ed35 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,7 +4,8 @@ | Version | Supported | |---------|-----------| -| 0.2.x | ✅ Yes | +| 1.0.x | ✅ Yes | +| 0.2.x | ❌ No | | 0.1.x | ❌ No | ## Reporting a Vulnerability @@ -113,6 +114,11 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + + # Required for SSE (Server-Sent Events) — disable response buffering + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 3600s; } } ``` @@ -123,7 +129,7 @@ server { | Header | Value | |--------|-------| -| `Content-Security-Policy` | `default-src 'self'; script-src 'self' 'nonce-…'; style-src 'self' 'nonce-…'; img-src 'self' data: blob:; object-src 'none'; frame-ancestors 'none'` | +| `Content-Security-Policy` | `default-src 'self'; script-src 'self' 'nonce-…'; style-src 'self' 'nonce-…'; style-src-attr 'unsafe-inline'; img-src 'self' data: blob:; object-src 'none'; frame-ancestors 'none'` | | `Strict-Transport-Security` | `max-age=31536000; includeSubDomains; preload` (production only) | | `X-Content-Type-Options` | `nosniff` | | `X-Frame-Options` | `DENY` | diff --git a/docs/diagrams/seq-dashboard.puml b/docs/diagrams/seq-dashboard.puml index 34f1772..15e6578 100644 --- a/docs/diagrams/seq-dashboard.puml +++ b/docs/diagrams/seq-dashboard.puml @@ -1,6 +1,6 @@ @startuml seq-dashboard !theme plain -title sofarr — Dashboard Request Sequence +title sofarr — Dashboard SSE Stream Sequence actor User as user participant "Browser\n(app.js)" as browser @@ -9,47 +9,28 @@ participant "MemoryCache" as cache participant "Poller" as poller participant "External\nServices" as ext -== Periodic Refresh (or Initial Load) == -user -> browser : (auto-refresh fires) +== SSE Connection (on login / page load) == +user -> browser : Login success\nor valid session activate browser -browser -> dashboard : GET /api/dashboard/user-downloads\n?refreshRate=5000&showAll=false +browser -> dashboard : GET /api/dashboard/stream\n(EventSource, Cookie: emby_user) activate dashboard -dashboard -> dashboard : Parse emby_user cookie\nExtract username, isAdmin -dashboard -> dashboard : Track client refresh rate\nin activeClients Map +dashboard -> dashboard : requireAuth: parse cookie\nextract username, isAdmin +dashboard -> dashboard : Set headers:\nContent-Type: text/event-stream\nX-Accel-Buffering: no +dashboard -> dashboard : Register in activeClients Map\n{ user, type:'sse', connectedAt } alt Polling disabled AND cache empty dashboard -> poller : pollAllServices() activate poller - poller -> ext : Parallel API calls\n(SAB, Sonarr, Radarr, qBit) + poller -> ext : Parallel API calls ext --> poller : Raw data - poller -> cache : set poll:* keys\n(TTL = 30s) + poller -> cache : set poll:* keys (TTL=30s) deactivate poller end -dashboard -> cache : get('poll:sab-queue') -cache --> dashboard : { slots, status, speed } -dashboard -> cache : get('poll:sab-history') -cache --> dashboard : { slots } -dashboard -> cache : get('poll:sonarr-tags') -cache --> dashboard : [{ instance, data }] -dashboard -> cache : get('poll:sonarr-queue') -cache --> dashboard : { records } (with embedded series) -dashboard -> cache : get('poll:sonarr-history') -cache --> dashboard : { records } -dashboard -> cache : get('poll:radarr-queue') -cache --> dashboard : { records } (with embedded movie) -dashboard -> cache : get('poll:radarr-history') -cache --> dashboard : { records } -dashboard -> cache : get('poll:radarr-tags') -cache --> dashboard : [{id, label}] -dashboard -> cache : get('poll:qbittorrent') -cache --> dashboard : [torrent, ...] - -dashboard -> dashboard : Build seriesMap from\nSonarr queue records -dashboard -> dashboard : Build moviesMap from\nRadarr queue records -dashboard -> dashboard : Build tag maps\n(id → label) - +== Initial Payload (sent immediately on connect) == +dashboard -> cache : get all poll:* keys +dashboard -> dashboard : Build seriesMap, moviesMap,\nsonarrTagMap, radarrTagMap alt showAll=true dashboard -> cache : get('emby:users') alt cache miss @@ -57,44 +38,30 @@ alt showAll=true ext --> dashboard : [{ Name, ... }] dashboard -> cache : set('emby:users', map, 60s) end - dashboard -> dashboard : Build embyUserMap\n(lowerName → displayName) +end +dashboard -> dashboard : Match SABnzbd/qBit downloads\nvs Sonarr/Radarr records\nextractUserTag / buildTagBadges +dashboard --> browser : data: { user, isAdmin, downloads } +browser -> browser : hideLoading()\nrenderDownloads() + +== Pushed Updates (on every poll cycle) == +loop Each poll cycle completes + poller -> poller : pollAllServices() complete + poller -> dashboard : onPollComplete callback fires + dashboard -> cache : get all poll:* keys + dashboard -> dashboard : Rebuild download payload + dashboard --> browser : data: { user, isAdmin, downloads } + browser -> browser : renderDownloads() (diff-based) end -group SABnzbd Queue Matching - loop each queue slot - dashboard -> dashboard : Match title vs Sonarr queue - dashboard -> dashboard : Match title vs Radarr queue - dashboard -> dashboard : extractAllTags() + extractUserTag(username)\nInclude if: showAll+anyTag OR matchedUserTag\nAttach allTags, matchedUserTag\nIf showAll: tagBadges = buildTagBadges(embyUserMap) - end -end +== Heartbeat (every 25s) == +dashboard --> browser : : heartbeat +note right : Keeps connection alive\nthrough idle-timeout proxies -group SABnzbd History Matching - loop each history slot - dashboard -> dashboard : Match title vs Sonarr/Radarr history - dashboard -> dashboard : Same tag extraction + inclusion logic - end -end - -group qBittorrent Matching - loop each torrent - dashboard -> dashboard : 1. Match vs Sonarr queue - dashboard -> dashboard : 2. Match vs Radarr queue - dashboard -> dashboard : 3. Match vs Sonarr history - dashboard -> dashboard : 4. Match vs Radarr history - dashboard -> dashboard : mapTorrentToDownload()\n→ enrich: allTags, matchedUserTag, tagBadges - end -end - -dashboard --> browser : { user, isAdmin,\ndownloads: [...] } +== Client Disconnects == +user -> browser : Close tab / logout +browser -> dashboard : TCP close (req 'close' event) +dashboard -> dashboard : offPollComplete(callback)\nclearInterval(heartbeat)\ndelete activeClients[key] deactivate dashboard - -browser -> browser : renderDownloads() (diff-based) -note right - createDownloadCard() renders tag badges: - - Normal: accent badge for matchedUserTag - - showAll: amber badges (unmatched tags) - accent badges (matched → show Emby displayName) -end note deactivate browser @enduml diff --git a/docs/diagrams/seq-polling.puml b/docs/diagrams/seq-polling.puml index 5014f2d..7866218 100644 --- a/docs/diagrams/seq-polling.puml +++ b/docs/diagrams/seq-polling.puml @@ -82,6 +82,10 @@ poller -> cache : set('poll:radarr-history', ..., cacheTTL) poller -> cache : set('poll:radarr-tags', ..., cacheTTL) poller -> cache : set('poll:qbittorrent', ..., cacheTTL) +poller -> poller : Notify SSE subscribers\npollSubscribers.forEach(cb => cb()) + +note over poller : Each registered SSE client\ncallback rebuilds its payload\nand writes a data: frame + poller -> poller : polling = false\nlog elapsed time deactivate poller diff --git a/docs/diagrams/state-poller.puml b/docs/diagrams/state-poller.puml index e3bf585..a4a24e9 100644 --- a/docs/diagrams/state-poller.puml +++ b/docs/diagrams/state-poller.puml @@ -32,7 +32,9 @@ state Polling { lock --> fetching fetching --> storing : All promises resolved fetching --> ErrorState : Any individual service\nerror (caught per-service) - storing --> timing + storing --> notifying : Cache updated + state "Notifying SSE\nsubscribers" as notifying + notifying --> timing timing --> [*] : polling = false } diff --git a/docs/diagrams/state-ui.puml b/docs/diagrams/state-ui.puml index 5642922..6254602 100644 --- a/docs/diagrams/state-ui.puml +++ b/docs/diagrams/state-ui.puml @@ -32,48 +32,42 @@ state FadeOutLogin { FadeOutLogin --> SplashScreen2 : Show splash\nwhile loading state SplashScreen2 as "Splash (loading data)" { - state "fetchUserDownloads()" as fetching + state "startSSE() — awaiting\nfirst SSE message" as fetching } SplashScreen2 --> Dashboard : Data loaded\ndismissSplash() state Dashboard { state "Rendering Cards" as rendering - state "Auto Refreshing" as refreshing state "Status Panel Open" as status_open state "Status Panel Closed" as status_closed [*] --> rendering - rendering --> refreshing : startAutoRefresh() - refreshing --> rendering : fetchUserDownloads()\n→ renderDownloads() + rendering --> rendering : SSE message received +→ renderDownloads() rendering --> rendering : Theme change - status_closed --> status_open : Click "Status" btn\n(admin only) + status_closed --> status_open : Click "Status" btn +(admin only) status_open --> status_closed : Click close (×) - status_open --> status_open : Auto-refresh\nrenderStatusPanel() + status_open --> status_open : 5s timer +→ renderStatusPanel() [*] --> status_closed - state "Refresh Rate" as rr { - state "1s" as r1 - state "5s (default)" as r5 - state "10s" as r10 - state "Off" as roff - r5 --> r1 : User selects - r5 --> r10 - r5 --> roff - r1 --> r5 - r1 --> r10 - r1 --> roff - r10 --> r1 - r10 --> r5 - r10 --> roff - roff --> r1 - roff --> r5 - roff --> r10 + state "SSE Connection" as sse { + state "Connecting" as sc + state "Connected" as scon + state "Reconnecting" as srec + sc --> scon : First message received + scon --> srec : Connection lost + srec --> scon : Browser auto-reconnects + scon --> sc : showAll toggle changed } } -Dashboard --> LoginForm : Logout\n(clear cookie,\nstopAutoRefresh) +Dashboard --> LoginForm : Logout +(stopSSE, +clear state) @enduml diff --git a/package.json b/package.json index 2e11fe6..efccb67 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sofarr", - "version": "0.2.0", + "version": "1.0.0", "description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish", "main": "server/index.js", "scripts": {