- Add includeEpisode:true to Sonarr queue and history API requests in both the poller and historyFetcher - Add extractEpisode() / gatherEpisodes() helpers in dashboard.js and history.js to build a sorted, deduplicated episodes array covering all records matching a download title (handles multi- episode packs and series packs) - Replace episodeInfo: sonarrMatch with episodes: gatherEpisodes() across all 8 assignment sites in dashboard.js - Add episodes field to /api/history/recent response items - Frontend: formatEpisodeInfo() renders S01E05 for single episodes or 'Multiple episodes' with hover tooltip listing all for packs - CSS: .episode-info and .multi-episode tooltip styles - ARCHITECTURE.md: update polling table and download/history schemas
55 KiB
sofarr — Architecture Documentation
Comprehensive technical documentation covering the full architecture of the sofarr application: a personal media download dashboard that aggregates downloads from SABnzbd, Sonarr, Radarr, and qBittorrent, filtered by Emby user identity.
Table of Contents
- System Overview
- Technology Stack
- Directory Structure
- Component Architecture
- Data Flow
- Authentication & Authorisation
- Background Polling & Caching
- Download Matching Pipeline
- API Reference
- Frontend Architecture
- Configuration
- Deployment
- Diagrams (Mermaid)
1. System Overview
sofarr is a single-page web application with a Node.js/Express backend. It provides a personalised view of media downloads by:
- Authenticating users against an Emby/Jellyfin media server.
- Aggregating download data from multiple *arr service instances and download clients.
- Filtering downloads by user — each user only sees media tagged with their username in Sonarr/Radarr.
- Presenting a real-time dashboard with progress, speeds, cover art, and status.
Admin users can view all users' downloads, see server status, cache statistics, and poll timings.
High-Level Architecture
flowchart TB
subgraph Browser["Browser (SPA)"]
login["Login Form"]
dash["Dashboard Cards"]
status["Status Panel\n(Admin only)"]
end
subgraph Server["Express Server (:3001)"]
auth_r["Auth Routes\n/api/auth"]
dash_r["Dashboard Routes\n/api/dashboard"]
emby_r["Emby Routes\n/api/emby"]
static_f["Static Files\npublic/"]
subgraph Utils["Utilities Layer"]
poller["Poller"]
cache["Cache"]
config["Config"]
qbt["qBittorrent"]
end
end
subgraph Ext["External Services"]
sab["SABnzbd\n(Usenet)"]
sonarr["Sonarr\n(TV)"]
radarr["Radarr\n(Movies)"]
qbittorrent["qBittorrent\n(Torrent)"]
emby["Emby / Jellyfin\n(Auth + User DB)"]
end
login -->|"POST /login"| auth_r
dash -->|"GET /stream SSE\nGET /user-downloads"| dash_r
status -->|"GET /status"| dash_r
auth_r -->|"authenticate"| emby
emby_r -->|"proxy"| emby
dash_r --> Utils
poller -->|"HTTP/API calls"| sab & sonarr & radarr
qbt -->|"HTTP/API calls"| qbittorrent
static_f -.->|"serve"| Browser
2. Technology Stack
Runtime & Framework
| Layer | Technology | Purpose |
|---|---|---|
| Runtime | Node.js 22 (Alpine) | Server runtime |
| Framework | Express 4.x | HTTP server, routing, middleware |
| HTTP Client | axios 1.x | External API communication |
| Frontend | Vanilla JS + CSS | Single-page app, no build step |
| Containerisation | Docker multi-stage (Alpine) | Production deployment |
| Logging | Custom logger + console.* |
File + stdout logging with levels |
Security Middleware
| Package | Purpose |
|---|---|
helmet 7.x |
HTTP security headers (CSP with per-request nonce, HSTS, referrer policy) |
express-rate-limit 7.x |
300 req/15 min general limiter; 10 failed attempts/15 min login limiter |
cookie-parser 1.x |
Signed cookie support (HMAC via COOKIE_SECRET) |
Auth & Session
| Component | Technology | Details |
|---|---|---|
| Identity | Emby API | POST /Users/authenticatebyname |
| Session cookie | httpOnly + sameSite: strict |
Signed when COOKIE_SECRET set |
| CSRF protection | Double-submit cookie pattern | csrf_token cookie + X-CSRF-Token header |
| Token store | JSON file (DATA_DIR/tokens.json) |
Atomic writes, 31-day TTL, hourly pruning |
Testing
| Tool | Purpose |
|---|---|
vitest 4.x |
Test runner (V8 coverage built-in) |
supertest 7.x |
HTTP integration testing |
nock 14.x |
HTTP interception at Node layer (works with CJS require('axios')) |
3. Directory Structure
sofarr/
├── server/ # Backend application
│ ├── index.js # Entry point: logging setup, server listen, poller start
│ ├── app.js # Express app factory (imported by index.js and tests)
│ ├── routes/
│ │ ├── auth.js # POST /login, GET /me, GET /csrf, POST /logout
│ │ ├── dashboard.js # GET /user-downloads, /user-summary, /status, /cover-art
│ │ ├── emby.js # Proxy routes to Emby API
│ │ ├── sabnzbd.js # Proxy routes to SABnzbd API
│ │ ├── sonarr.js # Proxy routes to Sonarr API
│ │ ├── radarr.js # Proxy routes to Radarr API
│ │ └── history.js # GET /api/history/recent — recently completed downloads
│ ├── middleware/
│ │ ├── requireAuth.js # httpOnly cookie auth enforcement
│ │ └── verifyCsrf.js # CSRF double-submit cookie validation
│ └── utils/
│ ├── cache.js # MemoryCache class (Map + TTL + stats)
│ ├── config.js # Multi-instance service configuration parser
│ ├── historyFetcher.js # Fetch + cache Sonarr/Radarr history; event classification
│ ├── logger.js # File logger (DATA_DIR/server.log)
│ ├── poller.js # Background polling engine + timing
│ ├── qbittorrent.js # qBittorrent client with auth + torrent mapping
│ ├── sanitizeError.js # Redacts secrets from error messages before logging
│ └── tokenStore.js # JSON file-backed Emby token store (atomic, TTL)
├── public/ # Static frontend (served by Express)
│ ├── index.html # HTML shell: splash, login, dashboard
│ ├── app.js # All frontend logic (auth, rendering, status)
│ ├── style.css # Themes, layout, responsive design
│ ├── favicon.ico # Multi-size favicon (16/32/48px)
│ ├── favicon-32.png # 32px PNG favicon
│ ├── favicon-192.png # 192px PNG (apple-touch-icon / PWA)
│ └── images/ # Logo / splash screen assets
├── tests/
│ ├── README.md # Testing approach, design decisions, coverage targets
│ ├── setup.js # Global setup: isolated DATA_DIR, rate-limit bypass
│ ├── unit/ # Pure unit tests (no HTTP)
│ └── integration/ # Supertest integration tests (nock for external HTTP)
├── docs/
│ ├── ARCHITECTURE.md # This document
│ └── diagrams/ # PlantUML source files
├── .gitea/workflows/
│ ├── ci.yml # Security audit + test/coverage CI jobs
│ ├── build-image.yml # Docker image build and push
│ └── create-release.yml # Release tagging workflow
├── Dockerfile # Multi-stage production container image (node:22-alpine)
├── docker-compose.yaml # Example compose deployment
├── vitest.config.js # Test runner configuration with per-file coverage thresholds
├── package.json # Dependencies and scripts
├── .env.sample # Annotated environment variable template
└── README.md # User-facing documentation
4. Component Architecture
4.1 Application Factory (server/app.js) and Entry Point (server/index.js)
server/app.js is a createApp(options?) factory that builds and returns the configured Express app instance. It is imported by both server/index.js (production) and the test suite. Keeping app creation separate from app.listen() means tests get a clean instance without triggering log-file setup, process.exit() calls, or the background poller.
createApp responsibilities:
- Configure
trust proxyfromTRUST_PROXYenv var - Apply
helmetwith a per-request CSP nonce (crypto.randomBytes(16)) for inline styles/scripts - Add
Permissions-Policyheader - Apply the general API rate limiter (300 req / 15 min per IP)
- Mount
cookie-parser(signed whenCOOKIE_SECRETis set) - Mount
express.json(64 KB body limit) - Expose
/healthand/readyendpoints (no auth, no rate limit) - Mount
/api/authroutes before CSRF middleware (login/logout are exempt) - Mount
verifyCsrffor all subsequent/apiroutes - Mount remaining route modules under
/api/* - Register global error handler (500 with sanitized message)
server/index.js entry point responsibilities:
- Load
.envviadotenv - Configure structured logging (
LOG_LEVEL) withconsole.*redirection to both stdout andDATA_DIR/server.log - Call
createApp(), servepublic/as static files, startapp.listen() - Start the background poller
4.2 Route Modules
| Module | Mount Point | Auth Required | CSRF Required | Purpose |
|---|---|---|---|---|
auth.js |
/api/auth |
No | No | Login, session check, CSRF token, logout |
dashboard.js |
/api/dashboard |
Yes (requireAuth) |
Yes (except /stream — GET) |
SSE stream, aggregated download data, status, cover-art proxy |
emby.js |
/api/emby |
Yes (requireAuth) |
Yes | Proxy to Emby API |
sabnzbd.js |
/api/sabnzbd |
Yes (requireAuth) |
Yes | Proxy to SABnzbd API |
sonarr.js |
/api/sonarr |
Yes (requireAuth) |
Yes | Proxy to Sonarr API |
radarr.js |
/api/radarr |
Yes (requireAuth) |
Yes | Proxy to Radarr API |
history.js |
/api/history |
Yes (requireAuth) |
No (GET only) | Recently completed downloads from Sonarr/Radarr history |
requireAuth (server/middleware/requireAuth.js) reads the emby_user cookie (signed if COOKIE_SECRET is set) and attaches the parsed { id, name, isAdmin } user to req.user. Returns 401 if the cookie is absent, tampered, or schema-invalid.
verifyCsrf (server/middleware/verifyCsrf.js) implements the double-submit cookie pattern for all state-changing requests (POST, PUT, PATCH, DELETE). Compares the csrf_token cookie against the X-CSRF-Token request header using crypto.timingSafeEqual. Safe methods (GET, HEAD, OPTIONS) are exempt. Auth routes are mounted before this middleware (login/logout are exempt by design — sameSite: strict provides equivalent protection).
Note: The proxy routes (
emby,sabnzbd,sonarr,radarr) exist for direct API access. The dashboard itself reads from the poller cache — it does not proxy through these routes.
4.3 Utility Modules
config.js — Parses service instances from environment variables. Supports both JSON array format (SONARR_INSTANCES=[{...}]) and legacy single-instance format (SONARR_URL + SONARR_API_KEY). Each instance gets an id derived from name or index.
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. 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).
tokenStore.js — JSON file-backed store (DATA_DIR/tokens.json) for Emby AccessTokens. Tokens are stored server-side and never sent to the client. Writes are atomic (write to .tmp then rename). Entries expire after 31 days (slightly longer than the maximum 30-day cookie). Pruning runs on startup and hourly.
sanitizeError.js — Redacts secrets from error message strings before they are logged or returned in API responses. Patterns: URL query-param secrets (apikey=, token=, etc.), HTTP auth headers (Authorization:, X-Emby-Authorization:, etc.), Bearer tokens, and basic-auth credentials in URLs.
historyFetcher.js — Fetches history records from all Sonarr/Radarr instances for a configurable date window (since). Results are cached under history:sonarr / history:radarr for 5 minutes. Exports classifySonarrEvent / classifyRadarrEvent (returns 'imported' | 'failed' | 'other') and invalidateHistoryCache.
logger.js — Simple file appender writing timestamped messages to DATA_DIR/server.log.
5. Data Flow
5.1 Polling Cycle
Every POLL_INTERVAL ms (default 5000), the poller fetches from all services in parallel:
| Task | API Call | Params |
|---|---|---|
| SABnzbd Queue | GET /api?mode=queue |
output=json |
| SABnzbd History | GET /api?mode=history |
limit=10 |
| Sonarr Tags | GET /api/v3/tag |
— |
| Sonarr Queue | GET /api/v3/queue |
includeSeries=true, includeEpisode=true |
| Sonarr History | GET /api/v3/history |
pageSize=10, includeEpisode=true |
| Radarr Queue | GET /api/v3/queue |
includeMovie=true |
| Radarr History | GET /api/v3/history |
pageSize=10 |
| Radarr Tags | GET /api/v3/tag |
— |
| qBittorrent | GET /api/v2/torrents/info |
— |
Results are stored in the cache under poll:* keys with a TTL of POLL_INTERVAL × 3.
5.2 SSE Stream
When a browser opens GET /api/dashboard/stream (after authentication):
- Server sets
Content-Type: text/event-stream, disables buffering (X-Accel-Buffering: no) - Immediately builds and sends the first payload (same matching logic as below)
- Registers a callback with the poller's
onPollCompletesubscriber set - After every subsequent poll cycle completes the callback fires, rebuilds the payload, and writes a
data:SSE frame - A 25-second heartbeat comment (
: heartbeat) keeps the connection alive through proxies - On client disconnect: deregisters callback, stops heartbeat, removes from active-client map
The browser's native EventSource API handles reconnection automatically on network interruption.
5.3 Download Matching
For each connected user the server:
- Reads all
poll:*keys from cache - Builds
seriesMapandmoviesMapfrom embedded objects in queue records - Builds
sonarrTagMapandradarrTagMapfrom tag data - For each SABnzbd queue slot → tries to match against Sonarr/Radarr queue records by title
- For each SABnzbd history slot → tries to match against Sonarr/Radarr history records
- For each qBittorrent torrent → tries to match against Sonarr/Radarr queue, then history
- For each match, resolves the series/movie, extracts the user tag, checks if it belongs to the requesting user
- Returns only the user's downloads (or all, if admin with
showAll=true)
6. Authentication & Authorisation
Flow
- User submits credentials (+ optional
rememberMe) via the login form - Backend applies the login rate limiter (max 10 failed attempts per IP per 15-minute window; successful requests don't count)
- Backend calls Emby
POST /Users/authenticatebynameusing a deterministicDeviceIdderived from the username — this causes Emby to reuse the existing session rather than creating a new one on every login - On success, fetches full user profile (
GET /Users/{id}) to determine admin status - Stores the Emby
AccessTokenintokenStore(server-side, never sent to the client) - Sets an
httpOnlyemby_usercookie containing only{ id, name, isAdmin }:rememberMe: true→ persistent cookie,Max-Age30 daysrememberMe: false→ session cookie (noMax-Age; expires when browser closes)secureflag enabled whenTRUST_PROXYis set (i.e. a TLS-terminating reverse proxy is in front)- Signed with HMAC when
COOKIE_SECRETis set
- Issues a
csrf_tokencookie (httpOnly: falseso JS can read it) containing a 32-byte random hex token - Returns
{ success: true, user, csrfToken }— the SPA storescsrfTokenin memory and sends it asX-CSRF-Tokenon all subsequent state-changing requests - All subsequent dashboard requests read the
emby_usercookie for identity viarequireAuth
Authorisation Matrix
| Feature | Regular User | Admin |
|---|---|---|
| View own downloads | ✓ | ✓ |
| View all users' downloads | ✗ | ✓ (showAll) |
| See download/target paths | ✗ | ✓ |
| See Sonarr/Radarr links | ✗ | ✓ |
| View status panel | ✗ | ✓ |
Tag Matching
Users are matched to downloads via tags in Sonarr/Radarr:
- Exact match: tag label (lowercased) === username (lowercased)
- Sanitised match: handles Ombi's tag mangling —
sanitizeTagLabel()converts to lowercase, replaces non-alphanumeric with hyphens, collapses, trims
7. Background Polling & Caching
Polling Modes
| Mode | POLL_INTERVAL |
Behaviour |
|---|---|---|
| Background | > 0 (e.g. 5000) |
Periodic fetch every N ms |
| On-demand | 0 / off / false |
Fetch triggered by first user request when cache is empty |
Cache Keys
| Key | Content | Source |
|---|---|---|
poll:sab-queue |
{ slots, status, speed, kbpersec } |
SABnzbd queue |
poll:sab-history |
{ slots } |
SABnzbd history |
poll:sonarr-tags |
[{ instance, data: [{id, label}] }] |
Sonarr tag API |
poll:sonarr-queue |
{ records } — includes embedded series objects |
Sonarr queue (includeSeries) |
poll:sonarr-history |
{ records } — lightweight, no embedded objects |
Sonarr history |
poll:radarr-queue |
{ records } — includes embedded movie objects |
Radarr queue (includeMovie) |
poll:radarr-history |
{ records } — lightweight, no embedded objects |
Radarr history |
poll:radarr-tags |
[{id, label}] |
Radarr tag API |
poll:qbittorrent |
[torrent, ...] |
qBittorrent all torrents |
emby:users |
Map<lowerName, displayName> |
Full Emby user list (60s TTL) |
history:sonarr |
[record, ...] — flat array with _instanceUrl / _instanceName |
Sonarr history (5 min TTL, fetched on-demand by /api/history/recent) |
history:radarr |
[record, ...] — flat array with _instanceUrl / _instanceName |
Radarr history (5 min TTL, fetched on-demand by /api/history/recent) |
TTL Strategy
- Polling enabled:
POLL_INTERVAL × 3— ensures data survives between polls even if one poll is slow - Polling disabled:
30000ms — stale after 30s, next request triggers a fresh fetch
Active Client Tracking
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.
8. Download Matching Pipeline
The core logic in dashboard.js matches raw download client data to *arr service metadata to determine which user each download belongs to.
Matching Strategy
For each download item (SABnzbd slot or qBittorrent torrent):
flowchart TD
Start(["Download item"]) --> SQ{"Sonarr QUEUE\nmatch (title)"}
SQ -->|yes| SQR["Resolve series via seriesMap\nextract user tag → check match"]
SQ -->|no| RQ{"Radarr QUEUE\nmatch (title)"}
RQ -->|yes| RQR["Resolve movie via moviesMap\nextract user tag → check match"]
RQ -->|no| SH{"Sonarr HISTORY\nmatch (title)"}
SH -->|yes| SHR["Resolve series via seriesId\nextract user tag → check match"]
SH -->|no| RH{"Radarr HISTORY\nmatch (title)"}
RH -->|yes| RHR["Resolve movie via movieId\nextract user tag → check match"]
RH -->|no| Skip(["Skip — unmatched"])
SQR & RQR & SHR & RHR --> Tagged{"Tag matches\nrequesting user?"}
Tagged -->|yes| Include(["Include in response"])
Tagged -->|no| Skip
Title Matching
Matches are bidirectional substring matches (case-insensitive):
rTitle.includes(downloadTitle) || downloadTitle.includes(rTitle)
Download Object Structure
Each matched download produces an object with:
| Field | Type | Description |
|---|---|---|
type |
'series' / 'movie' / 'torrent' |
Media type |
title |
string | Raw download title |
coverArt |
string / null | Poster URL from *arr |
status |
string | Download status |
progress |
string | Percentage complete |
size / mb / mbmissing |
string / number | Size info |
speed |
string | Current download speed |
eta |
string | Estimated time remaining |
seriesName / movieName |
string | Friendly media title |
episodes |
{season, episode, title}[] |
(Series only) Episodes covered by this download, sorted by season/episode. Single-episode downloads have one entry; series packs have multiple. Empty array if Sonarr has no episode data. |
allTags |
string[] | All resolved tag labels on the series/movie |
matchedUserTag |
string / null | Tag label matching the requesting user, or null |
tagBadges |
{label, matchedUser}[] / undefined |
(Admin showAll only) Each tag classified against full Emby user list |
importIssues |
string[] / null | Import warning/error messages |
downloadPath |
string / null | (Admin) Download client path |
targetPath |
string / null | (Admin) *arr target path |
arrLink |
string / null | (Admin) Link to *arr web UI |
9. API Reference
POST /api/auth/login
Authenticate a user via Emby. Rate-limited to 10 failed attempts per IP per 15 minutes.
Request Body:
{ "username": "string", "password": "string", "rememberMe": false }
| Field | Required | Description |
|---|---|---|
username |
Yes | Max 128 chars, must be a non-empty string |
password |
Yes | Max 256 chars |
rememberMe |
No | true = 30-day persistent cookie; false/omitted = session cookie |
Response (200):
{
"success": true,
"user": { "id": "string", "name": "string", "isAdmin": false },
"csrfToken": "64-char hex string"
}
Response (400): Invalid input (empty/overlong username or password).
Response (401):
{ "success": false, "error": "Invalid username or password" }
Response (429): Too many failed attempts from this IP.
Side Effects:
- Sets
emby_usercookie:httpOnly,sameSite: strict,securein production, signed ifCOOKIE_SECRETis set. Payload:{ id, name, isAdmin }. The EmbyAccessTokenis never included. - Sets
csrf_tokencookie:httpOnly: false(JS-readable), same security flags. 64-char hex. - Stores the Emby
AccessTokenserver-side intokenStore(used only for server-side Emby logout).
GET /api/auth/me
Check current session (no auth required — returns unauthenticated state rather than 401).
Response (authenticated):
{ "authenticated": true, "user": { "id": "string", "name": "string", "isAdmin": false } }
Response (not authenticated):
{ "authenticated": false }
GET /api/auth/csrf
Issue a fresh CSRF token. Used by the SPA after a page reload when the in-memory csrfToken variable is lost.
Response (200):
{ "csrfToken": "64-char hex string" }
Side Effect: Sets a new csrf_token cookie.
POST /api/auth/logout
Clear session and revoke the Emby token server-side. Does not require a CSRF token (auth routes are mounted before verifyCsrf; sameSite: strict provides equivalent protection).
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:
{
"user": "Alice",
"isAdmin": false,
"downloads": [ /* download objects — same shape as /user-downloads */ ]
}
The connection is kept alive with : heartbeat comments every 25 seconds. The browser's EventSource reconnects automatically on failure.
GET /api/dashboard/user-downloads
Fetch downloads for the authenticated user (single HTTP request, no streaming).
Query Parameters:
| Param | Type | Description |
|---|---|---|
showAll |
"true" |
(Admin) Show all users' downloads |
Response (200):
{
"user": "string",
"isAdmin": true,
"downloads": [ /* download objects */ ]
}
GET /api/dashboard/status
Admin-only server status.
Response (200):
{
"server": {
"uptimeSeconds": 3600,
"nodeVersion": "v18.19.0",
"memoryUsageMB": 45.2,
"heapUsedMB": 28.1,
"heapTotalMB": 35.0
},
"polling": {
"enabled": true,
"intervalMs": 5000,
"lastPoll": {
"totalMs": 1234,
"timestamp": "2026-05-16T00:00:00.000Z",
"tasks": [
{ "label": "SABnzbd Queue", "ms": 120 },
{ "label": "Sonarr Queue", "ms": 890 }
]
}
},
"cache": {
"entryCount": 9,
"totalSizeBytes": 51200,
"entries": [
{ "key": "poll:sab-queue", "sizeBytes": 1024, "itemCount": 3, "ttlRemainingMs": 12000, "expired": false }
]
},
"clients": [
{ "user": "Alice", "type": "sse", "connectedAt": 1715817600000, "lastSeen": 1715817600000 }
]
}
GET /api/dashboard/user-summary
Admin-only per-user download counts (fetches live from APIs, not cached).
Response (200):
[
{ "username": "Alice", "seriesCount": 12, "movieCount": 5 }
]
GET /api/history/recent
Returns recently completed (imported or failed) downloads from Sonarr/Radarr history for the authenticated user, filtered to the last days days.
Query Parameters:
| Param | Type | Default | Description |
|---|---|---|---|
days |
integer | RECENT_COMPLETED_DAYS env (default 7) |
How many days back to search. Capped at 90. |
showAll |
"true" |
— | (Admin) Return records for all tagged users, not just the current user |
Response (200):
{
"user": "Alice",
"isAdmin": false,
"days": 7,
"history": [
{
"type": "series",
"outcome": "imported",
"title": "Show.S01E01.720p",
"seriesName": "My Show",
"episodes": [
{ "season": 1, "episode": 1, "title": "Pilot" }
],
"coverArt": "https://…/poster.jpg",
"completedAt": "2026-05-15T18:00:00.000Z",
"quality": "720p",
"instanceName": "Main Sonarr",
"arrLink": "https://sonarr.example.com/series/my-show",
"allTags": ["alice"],
"matchedUserTag": "alice",
"arrRecordId": 1234,
"failureMessage": null
}
]
}
outcomeis"imported"or"failed". Records with other event types (e.g.grabbed) are filtered out.episodesis a sorted array of{ season, episode, title }objects. Single-episode downloads have one entry; series packs have multiple.titleisnullif not returned by Sonarr. Empty array if Sonarr has no episode data.failureMessageis only included when the authenticated user is an admin andoutcomeis"failed".arrRecordIdis only included for admin users.- Results are sorted newest first.
- History data is cached server-side for 5 minutes (
history:sonarr/history:radarrcache keys).
10. Frontend Architecture
The frontend is a vanilla JavaScript SPA with no build step. All logic resides in app.js, styled by style.css, and structured by index.html.
UI States
stateDiagram-v2
[*] --> SplashScreen : Page load
SplashScreen --> LoginForm : No session
SplashScreen --> Dashboard : Valid session
LoginForm --> Dashboard : Auth success
Dashboard --> LoginForm : Logout
state Dashboard {
[*] --> ActiveDownloads
ActiveDownloads --> ActiveDownloads : SSE update
state StatusPanel {
[*] --> Closed
Closed --> Open : Click Status (admin)
Open --> Closed : Click close
Open --> Open : 5s refresh
}
}
Key Frontend Functions
| Function | Purpose |
|---|---|
checkAuthentication() |
On load: check session → show dashboard or login |
handleLogin() |
Authenticate, fade login → splash → dashboard |
startSSE() |
Open EventSource to /stream; handles incoming data + first-message loading hide |
stopSSE() |
Close EventSource and cancel reconnect timer |
renderDownloads() |
Diff-based card rendering (create/update/remove) |
createDownloadCard() |
Build DOM for a single download card; renders tag badges |
updateDownloadCard() |
Update existing card in-place (progress, speed, etc.) |
toggleStatusPanel() |
Show/hide admin status panel |
renderStatusPanel() |
Build status HTML (server, polling, SSE clients, cache) |
initThemeSwitcher() |
Light / Dark / Mono theme support |
Themes
Three CSS themes via data-theme attribute on <html>:
- Light — Purple gradient header, white cards
- Dark — Dark surfaces, muted accents
- Mono — Monochrome, minimal colour
Theme selection persists in localStorage.
Tag Badge Rendering
Download cards render tag badges in the card header:
- Normal user view: a single accent-coloured badge showing the tag label that matched the current user's username (via
matchedUserTag). - Admin
showAllview: all tags on the download are rendered usingtagBadges[]:- 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)
Live Push via SSE
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.
11. Configuration
Environment Variables
Core
| Variable | Required | Default | Description |
|---|---|---|---|
PORT |
No | 3001 |
Server listen port |
NODE_ENV |
No | — | Set to production for production logging and startup validation. Does not control cookie secure flag or CSP upgrade-insecure-requests (both gated on TRUST_PROXY). |
DATA_DIR |
No | ./data |
Directory for tokens.json and server.log. Must be writable by the server process. In Docker: /app/data (named volume). |
COOKIE_SECRET |
No | — | If set, signs all session cookies with HMAC-SHA256. Strongly recommended in production. |
TRUST_PROXY |
No | — | Express trust proxy setting. Set to 1 (or a hop count) when behind a reverse proxy (nginx, Traefik, etc.) so req.ip and req.secure are correct. |
TLS_ENABLED |
No | true |
Set to false to disable HTTPS and run plain HTTP (e.g. when TLS is terminated by a reverse proxy). |
TLS_CERT |
No | certs/snakeoil.crt |
Path to the TLS certificate file (PEM). Defaults to the bundled self-signed snakeoil certificate. |
TLS_KEY |
No | certs/snakeoil.key |
Path to the TLS private key file (PEM). Defaults to the bundled snakeoil key. |
Emby
| Variable | Required | Default | Description |
|---|---|---|---|
EMBY_URL |
Yes | — | Emby/Jellyfin base URL (e.g. https://emby.example.com) |
EMBY_API_KEY |
Yes | — | Emby API key (used by the poller to list users for tag badge classification) |
Service Instances
| Variable | Required | Default | Description |
|---|---|---|---|
SONARR_INSTANCES |
Yes* | — | JSON array of Sonarr instances |
SONARR_URL |
Yes* | — | Legacy: single Sonarr URL |
SONARR_API_KEY |
Yes* | — | Legacy: single Sonarr API key |
RADARR_INSTANCES |
Yes* | — | JSON array of Radarr instances |
RADARR_URL |
Yes* | — | Legacy: single Radarr URL |
RADARR_API_KEY |
Yes* | — | Legacy: single Radarr API key |
SABNZBD_INSTANCES |
Yes* | — | JSON array of SABnzbd instances |
SABNZBD_URL |
Yes* | — | Legacy: single SABnzbd URL |
SABNZBD_API_KEY |
Yes* | — | Legacy: single SABnzbd API key |
QBITTORRENT_INSTANCES |
No | — | JSON array of qBittorrent instances |
* Either *_INSTANCES (JSON array) or legacy *_URL + *_API_KEY format is required.
Tuning
| Variable | Required | Default | Description |
|---|---|---|---|
POLL_INTERVAL |
No | 5000 |
Poll interval in ms. Set to 0, off, or false to disable background polling (on-demand mode). |
RECENT_COMPLETED_DAYS |
No | 7 |
Default lookback window (days) for GET /api/history/recent. Overridable per-request via ?days=. Max 90. |
LOG_LEVEL |
No | info |
debug, info, warn, error, silent |
Instance JSON Format
[
{
"name": "main",
"url": "https://sonarr.example.com",
"apiKey": "your-api-key"
},
{
"name": "4k",
"url": "https://sonarr4k.example.com",
"apiKey": "your-4k-api-key"
}
]
qBittorrent instances use username and password instead of apiKey.
12. Deployment
Docker image
The production image uses a two-stage build on node:22-alpine:
depsstage — runsnpm ci --omit=devto install only production dependencies.runtimestage — copiesnode_modules,server/,public/, andpackage.json. Runs as the built-in non-rootnodeuser (UID 1000)./app/datais owned bynodefor writable token store and logs.
Key environment variables set in the image:
NODE_ENV=production— enables production startup validation and loggingDATA_DIR=/app/data— token store and log file location- TLS is enabled by default using the bundled snakeoil self-signed certificate (
certs/snakeoil.crt). SetTLS_CERT/TLS_KEYto your own certificate, or setTLS_ENABLED=falsewhen terminating TLS at a reverse proxy.
Docker Compose
services:
sofarr:
image: docker.i3omb.com/sofarr:latest
container_name: sofarr
restart: unless-stopped
ports:
- "3001:3001" # HTTPS by default (snakeoil cert if no TLS_CERT set)
environment:
- NODE_ENV=production
- DATA_DIR=/app/data
- COOKIE_SECRET=change-me-to-a-long-random-string
# Option A: direct TLS (default). Supply your own cert/key:
# - TLS_CERT=/app/certs/server.crt
# - TLS_KEY=/app/certs/server.key
# Option B: behind a TLS-terminating reverse proxy:
# - TLS_ENABLED=false
# - TRUST_PROXY=1
- EMBY_URL=https://emby.example.com
- EMBY_API_KEY=your-emby-api-key
- SONARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
- RADARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
- SABNZBD_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
- QBITTORRENT_INSTANCES=[{"name":"main","url":"...","username":"...","password":"..."}]
- POLL_INTERVAL=5000
- LOG_LEVEL=info
volumes:
- sofarr-data:/app/data
# Uncomment to supply your own certificate (Option A):
# - /path/to/server.crt:/app/certs/server.crt:ro
# - /path/to/server.key:/app/certs/server.key:ro
volumes:
sofarr-data:
Security hardening checklist
- Use HTTPS — TLS is on by default (snakeoil cert). Supply
TLS_CERT/TLS_KEYpointing to a CA-signed certificate for trusted HTTPS. Alternatively terminate TLS at a reverse proxy and setTLS_ENABLED=false+TRUST_PROXY=1. - Set
COOKIE_SECRET— enables HMAC-signed cookies, preventing client-side forgery. - Set
TRUST_PROXY=1only when a TLS-terminating reverse proxy sits in front — ensuresreq.secureis correct and the CSPupgrade-insecure-requests+securecookie flag fire correctly. - Mount a named volume for
DATA_DIR— token store and log file survive container recreates. - Rate limiting — the login endpoint is limited to 10 failed attempts per IP per 15 minutes; all API endpoints share a 300 req/15 min window. Set
TRUST_PROXYcorrectly so the client IP is not the proxy's IP.
CI / CD
The .gitea/workflows/ directory contains three pipeline definitions:
| File | Trigger | Purpose |
|---|---|---|
ci.yml |
Every push / PR | Security audit (npm audit --audit-level=high) + tests with V8 coverage |
build-image.yml |
Push to main / develop |
Build and push Docker image to docker.i3omb.com |
create-release.yml |
Tag push (v*) |
Create a Gitea release |
Diagrams are written in Mermaid and render natively in Gitea — no CI workflow required. See Section 13.
13. Diagrams
All diagrams are written in Mermaid and render natively in Gitea and GitHub markdown.
13.1 Component Diagram
graph TB
subgraph Browser
html[index.html]
appjs[app.js]
css[style.css]
html -->|loads| appjs
html -->|loads| css
end
subgraph Express Server
entry[index.js\nEntry Point]
appfactory[app.js\ncreateApp factory]
subgraph Middleware
hm[helmet\nCSP nonce + HSTS]
rl[express-rate-limit\nAPI + login]
cp[cookie-parser\nsigned cookies]
ej[express.json\n64kb limit]
es[express.static]
requireauth[requireAuth.js]
verifycsrf[verifyCsrf.js\ndouble-submit]
end
subgraph Routes
auth[auth.js\n/api/auth\npre-CSRF]
dashboard[dashboard.js\n/api/dashboard\n+SSE /stream]
emby_r[emby.js\n/api/emby]
sab_r[sabnzbd.js\n/api/sabnzbd]
sonarr_r[sonarr.js\n/api/sonarr]
radarr_r[radarr.js\n/api/radarr]
history_r[history.js\n/api/history]
end
subgraph Utilities
poller[poller.js]
cache[cache.js\nMemoryCache]
config[config.js]
qbt[qbittorrent.js\nQBittorrentClient]
tokenstore[tokenStore.js\ntokens.json]
sanitize[sanitizeError.js]
logger[logger.js]
historyfetcher[historyFetcher.js]
end
entry --> appfactory
entry --> es
entry --> poller
appfactory --> hm & rl & cp & ej
appfactory -->|pre-CSRF| auth
appfactory --> verifycsrf
appfactory --> dashboard & emby_r & sab_r & sonarr_r & radarr_r & history_r
dashboard & emby_r & sab_r & sonarr_r & radarr_r & history_r --> requireauth
auth --> tokenstore
dashboard --> cache & poller & config & qbt
history_r --> cache & config & historyfetcher
historyfetcher --> cache & config
poller --> cache & config & qbt & logger
qbt --> config & logger
auth & dashboard -.-> sanitize
end
subgraph External Services
emby[Emby / Jellyfin]
sab[SABnzbd]
sonarr[Sonarr]
radarr[Radarr]
qbit[qBittorrent]
end
auth --> emby
dashboard --> emby
poller --> sab & sonarr & radarr
qbt --> qbit
emby_r --> emby
sab_r --> sab
sonarr_r --> sonarr
radarr_r --> radarr
appjs -->|POST /login\nGET /me\nGET /csrf\nPOST /logout| auth
appjs -->|GET /stream SSE\nGET /user-downloads\nGET /status| dashboard
es -->|serve static| html
13.2 Authentication Sequence
sequenceDiagram
actor User
participant Browser as Browser (app.js)
participant Auth as Express /api/auth
participant Tokens as TokenStore (tokens.json)
participant Emby as Emby Server
rect rgb(240,240,255)
Note over Browser,Auth: Page Load
Browser->>Auth: GET /api/auth/me
Auth->>Auth: Read emby_user cookie (signed if COOKIE_SECRET)
alt Cookie valid
Auth-->>Browser: { authenticated: true, user }
Browser->>Auth: GET /api/auth/csrf
Auth-->>Browser: { csrfToken } + Set csrf_token cookie
Browser->>Browser: store csrfToken in memory
Browser->>Browser: showDashboard() + startSSE()
else No cookie / tampered
Auth-->>Browser: { authenticated: false }
Browser->>Browser: showLogin()
end
end
rect rgb(240,255,240)
Note over Browser,Emby: Login
User->>Browser: Enter credentials (+ rememberMe)
Browser->>Auth: POST /api/auth/login
Note right of Auth: Rate limit: max 10 failed\nattempts per IP / 15 min
Auth->>Emby: POST /Users/authenticatebyname\nDeviceId = sha256(username)[0:16]
alt Valid credentials
Emby-->>Auth: { User.Id, AccessToken }
Auth->>Emby: GET /Users/{id}
Emby-->>Auth: { Name, Policy.IsAdministrator }
Auth->>Tokens: storeToken(userId, AccessToken)
Note right of Tokens: Server-side only\n31-day TTL, atomic write
Auth->>Auth: Set emby_user cookie\nhttpOnly, sameSite=strict\nsecure (if TRUST_PROXY)\nrememberMe → Max-Age 30d
Auth->>Auth: Set csrf_token cookie\nhttpOnly=false, sameSite=strict
Auth-->>Browser: { success: true, user, csrfToken }
Browser->>Browser: showDashboard() + startSSE()
else Invalid credentials
Emby-->>Auth: 401
Auth-->>Browser: { success: false, error }
end
end
rect rgb(255,245,230)
Note over Browser,Auth: Logout
User->>Browser: Click Logout
Browser->>Browser: stopSSE()
Browser->>Auth: POST /api/auth/logout
Auth->>Tokens: getToken(userId)
Tokens-->>Auth: { accessToken }
Auth->>Emby: POST /Sessions/Logout
Auth->>Tokens: clearToken(userId)
Auth->>Auth: clearCookie(emby_user, csrf_token)
Auth-->>Browser: { success: true }
Browser->>Browser: showLogin()
end
13.3 Dashboard SSE Stream Sequence
sequenceDiagram
actor User
participant Browser as Browser (app.js)
participant Dashboard as Express /api/dashboard
participant Cache as MemoryCache
participant Poller
participant Ext as External Services
User->>Browser: Login success / valid session
Browser->>Dashboard: GET /api/dashboard/stream (EventSource)
Dashboard->>Dashboard: requireAuth: extract user/isAdmin
Dashboard->>Dashboard: Set Content-Type: text/event-stream\nRegister in activeClients
opt Polling disabled AND cache empty
Dashboard->>Poller: pollAllServices()
Poller->>Ext: Parallel API calls
Ext-->>Poller: Raw data
Poller->>Cache: set poll:* keys (TTL=30s)
end
Dashboard->>Cache: get all poll:* keys
Dashboard->>Dashboard: Build maps, match downloads\nextractUserTag / buildTagBadges
Dashboard-->>Browser: data: { user, isAdmin, downloads }
Browser->>Browser: hideLoading() + renderDownloads()
loop Every poll cycle
Poller->>Poller: pollAllServices() complete
Poller->>Dashboard: onPollComplete callback fires
Dashboard->>Cache: get all poll:* keys
Dashboard->>Dashboard: Rebuild payload
Dashboard-->>Browser: data: { user, isAdmin, downloads }
Browser->>Browser: renderDownloads() diff-based
end
Note over Dashboard,Browser: : heartbeat every 25s keeps connection alive
User->>Browser: Close tab / logout
Browser->>Dashboard: TCP close (req close event)
Dashboard->>Dashboard: offPollComplete(cb)\nclearInterval(heartbeat)\ndelete activeClients[key]
13.4 Background Polling Cycle
sequenceDiagram
participant Entry as index.js (startup)
participant Poller
participant Config
participant SAB as SABnzbd (per instance)
participant Sonarr as Sonarr (per instance)
participant Radarr as Radarr (per instance)
participant QBT as qBittorrent Client
participant Cache as MemoryCache
Entry->>Poller: startPoller()
alt POLL_INTERVAL > 0
Poller->>Poller: pollAllServices() immediate
Poller->>Poller: setInterval(pollAllServices, POLL_INTERVAL)
else POLL_INTERVAL = 0
Poller-->>Entry: on-demand mode
end
Note over Poller: Each poll cycle
Poller->>Poller: polling flag check (skip if concurrent)
Poller->>Poller: polling = true
Poller->>Config: getSABnzbdInstances() / getSonarrInstances() / getRadarrInstances()
Config-->>Poller: instance configs
Note over Poller,Cache: All 9 fetches run in parallel via Promise.all, each wrapped in timed()
Poller->>SAB: GET /api?mode=queue
SAB-->>Poller: { queue: { slots, status, speed } }
Poller->>SAB: GET /api?mode=history&limit=10
SAB-->>Poller: { history: { slots } }
Poller->>Sonarr: GET /api/v3/tag + queue + history
Sonarr-->>Poller: tags, queue records (includeSeries), history
Poller->>Radarr: GET /api/v3/tag + queue + history
Radarr-->>Poller: tags, queue records (includeMovie), history
Poller->>QBT: getTorrents()
QBT-->>Poller: [{ name, progress, ... }]
Poller->>Poller: Record per-task timings\nlastPollTimings = { totalMs, timestamp, tasks }
Poller->>Cache: set poll:* keys (TTL = POLL_INTERVAL × 3)
Poller->>Poller: Notify SSE subscribers (forEach cb())
Poller->>Poller: polling = false
13.5 Server Class Diagram
classDiagram
class EntryPoint["index.js (EntryPoint)"] {
+startPoller()
+app.listen()
setupLogging()
serveStatic()
}
class createApp["app.js (createApp factory)"] {
+createApp(skipRateLimits?) Express
mountHelmet()
mountRateLimiters()
mountRoutes()
mountErrorHandler()
}
class AuthRouter["auth.js (Router)"] {
+POST /login
+GET /me
+GET /csrf
+POST /logout
authenticateViaEmby()
issueCookies()
revokeToken()
}
class DashboardRouter["dashboard.js (Router)"] {
-activeClients Map
+GET /stream SSE
+GET /user-downloads
+GET /user-summary
+GET /status
+GET /cover-art
buildDownloadPayload()
extractUserTag()
buildTagBadges()
getEmbyUsers()
}
class RequireAuth["requireAuth.js (Middleware)"] {
+requireAuth(req, res, next)
readCookie()
validateSchema()
}
class VerifyCsrf["verifyCsrf.js (Middleware)"] {
+verifyCsrf(req, res, next)
timingSafeEqual()
}
class MemoryCache {
-store Map
+get(key) any
+set(key, value, ttlMs)
+invalidate(key)
+clear()
+getStats() CacheStats
}
class Poller {
-POLL_INTERVAL number
-polling boolean
-subscribers Set
+startPoller()
+stopPoller()
+pollAllServices()
+onPollComplete(cb)
+offPollComplete(cb)
+getLastPollTimings() PollTimings
}
class Config {
+getSABnzbdInstances() Instance[]
+getSonarrInstances() Instance[]
+getRadarrInstances() Instance[]
+getQbittorrentInstances() Instance[]
}
class QBittorrentClient {
-url string
-authCookie string
+login() bool
+getTorrents() Torrent[]
+makeRequest(endpoint)
}
class TokenStore {
-STORE_PATH string
-TOKEN_TTL_MS 31days
+storeToken(userId, token)
+getToken(userId)
+clearToken(userId)
atomicWrite()
pruneExpired()
}
class SanitizeError {
+sanitizeError(err) string
redactQueryParams()
redactAuthHeaders()
}
EntryPoint --> createApp : createApp()
EntryPoint --> Poller : startPoller()
createApp --> AuthRouter : mount pre-CSRF
createApp --> VerifyCsrf : apply to /api
createApp --> DashboardRouter
DashboardRouter --> RequireAuth
DashboardRouter --> MemoryCache
DashboardRouter --> Poller
DashboardRouter --> Config
DashboardRouter ..> SanitizeError
AuthRouter --> TokenStore
AuthRouter ..> SanitizeError
Poller --> MemoryCache
Poller --> Config
Poller --> QBittorrentClient
QBittorrentClient --> Config
13.6 Data Model Diagram
classDiagram
class Download {
+type series|movie|torrent
+title string
+coverArt string
+status string
+progress string
+size string
+mb string
+mbmissing string
+speed string
+eta string
+seriesName string
+movieName string
+allTags string[]
+matchedUserTag string
+tagBadges TagBadge[]
+importIssues string[]
+downloadPath string
+targetPath string
+arrLink string
+seeds number
+peers number
+availability string
+hash string
+completedAt string
}
class TagBadge {
+label string
+matchedUser string
}
class APIResponse {
+user string
+isAdmin boolean
+downloads Download[]
}
class SSEEvent {
+user string
+isAdmin boolean
+downloads Download[]
}
class StatusResponse {
+server ServerInfo
+polling PollingInfo
+cache CacheStats
+clients ClientInfo[]
}
class SessionCookie {
+id string
+name string
+isAdmin boolean
}
class SABnzbdQueueSlot {
+filename string
+percentage string
+mb string
+mbmissing string
+timeleft string
+status string
}
class qBittorrentTorrent {
+name string
+hash string
+progress float
+state string
+dlspeed number
+eta number
+num_seeds number
+num_leechs number
+availability number
}
class SonarrQueueRecord {
+seriesId number
+series SonarrSeries
+title string
+trackedDownloadStatus string
+statusMessages StatusMessage[]
}
class RadarrQueueRecord {
+movieId number
+movie RadarrMovie
+title string
+trackedDownloadStatus string
+statusMessages StatusMessage[]
}
APIResponse "1" *-- "many" Download
SSEEvent "1" *-- "many" Download
Download "1" *-- "many" TagBadge
SABnzbdQueueSlot ..> Download : matched and transformed
qBittorrentTorrent ..> Download : mapTorrentToDownload()
SonarrQueueRecord ..> Download : coverArt, seriesName, tags
RadarrQueueRecord ..> Download : coverArt, movieName, tags
13.7 Frontend UI State Diagram
stateDiagram-v2
[*] --> SplashScreen : Page load
SplashScreen --> CheckAuth : checkAuthentication()
state CheckAuth <<choice>>
CheckAuth --> LoginForm : No session
CheckAuth --> Dashboard : Valid session
state LoginForm {
[*] --> Idle
Idle --> Submitting : Submit form
Submitting --> Error : Auth failed
Error --> Submitting : Re-submit
Submitting --> [*] : Auth success
}
LoginForm --> Dashboard : Auth success\n(fade transition)
state Dashboard {
[*] --> Rendering
Rendering --> Rendering : SSE message → renderDownloads()
Rendering --> Rendering : Theme change
state SSEConnection {
[*] --> Connecting
Connecting --> Connected : First message
Connected --> Reconnecting : Connection lost
Reconnecting --> Connected : Auto-reconnect
Connected --> Connecting : showAll toggled
}
state StatusPanel {
[*] --> Closed
Closed --> Open : Click Status (admin)
Open --> Closed : Click close
Open --> Open : 5s timer refresh
}
}
Dashboard --> LoginForm : Logout (stopSSE)
13.8 Poller State Diagram
stateDiagram-v2
[*] --> CheckConfig : startPoller()
state CheckConfig <<choice>>
CheckConfig --> Disabled : POLL_INTERVAL = 0
CheckConfig --> Idle : POLL_INTERVAL > 0
state Disabled {
[*] --> OnDemand
OnDemand : No background timer.\nData fetched when dashboard\nrequest finds empty cache.
}
Disabled --> Polling : dashboard triggers pollAllServices()
Polling --> Disabled : Poll complete (on-demand)
Idle --> Polling : setInterval fires\nor immediate first poll
state Polling {
[*] --> Locked
Locked : polling = true
Locked --> Fetching
Fetching --> Storing : All promises resolved
Fetching --> HandleError : Per-service error (caught)
Storing --> Notifying : Cache updated\nTTL = POLL_INTERVAL × 3
Notifying : Notify SSE subscribers
Notifying --> Done
Done : polling = false
Done --> [*]
}
state HandleError {
[*] --> LogError
LogError : Log error, polling = false
}
Polling --> Idle : Poll complete
HandleError --> Idle : Next interval
state ConcurrentSkip {
[*] --> Skip
Skip : polling === true, skip cycle
}
Idle --> ConcurrentSkip : Interval fires while\nprevious still running
ConcurrentSkip --> Idle : Log skip
13.9 Download Matching Flow
flowchart TD
A([Start: user request]) --> B[Read all poll:* keys from MemoryCache]
B --> C[Build seriesMap, moviesMap\nsonarrTagMap, radarrTagMap]
C --> D{showAll?}
D -->|yes| E[Fetch Emby user list\ncached 60s → embyUserMap]
D -->|no| F
E --> F[userDownloads = empty array]
F --> G[/SABnzbd queue slots/]
G --> H{Matches Sonarr queue?}
H -->|yes| I[Resolve series\nextractAllTags + extractUserTag]
I --> J{showAll + anyTag\nor matchedUserTag?}
J -->|yes| K[Build Download object\nAdd tagBadges if showAll\nAdd importIssues, admin fields]
K --> L[Push to userDownloads]
H --> M{Matches Radarr queue?}
M -->|yes| N[Resolve movie\nextractAllTags + extractUserTag]
N --> J
L --> G
G --> O[/SABnzbd history slots/]
O --> P{Matches Sonarr history?}
P -->|yes| Q[Resolve series\nBuild Download type=series\nAdd completedAt]
Q --> R{showAll+anyTag\nor matchedUserTag?}
R -->|yes| S[Push to userDownloads]
P --> T{Matches Radarr history?}
T -->|yes| U[Resolve movie\nBuild Download type=movie\nAdd completedAt]
U --> R
S --> O
O --> V[/qBittorrent torrents/]
V --> W{Matches Sonarr queue?}
W -->|yes| X[mapTorrentToDownload\n+ enrich with series]
X --> Y{Tag matches?}
Y -->|yes| Z[Push to userDownloads]
W --> AA{Matches Radarr queue?}
AA -->|yes| AB[mapTorrentToDownload\n+ enrich with movie]
AB --> Y
AA --> AC{Matches Sonarr history?}
AC -->|yes| AD[Resolve series via seriesMap]
AD --> Y
AC --> AE{Matches Radarr history?}
AE -->|yes| AF[Resolve movie via moviesMap]
AF --> Y
AE -->|no| AG[Skip - unmatched torrent]
Z --> V
AG --> V
V --> AH([Return JSON\nuser, isAdmin, downloads])
style K fill:#d4edda
style Q fill:#d4edda
style U fill:#d4edda
style X fill:#d4edda
style AB fill:#d4edda
style AD fill:#d4edda
style AF fill:#d4edda
style AG fill:#f8d7da