ARCHITECTURE.md: - Cookie secure flag: NODE_ENV → TRUST_PROXY (3 locations) - upgrade-insecure-requests: document it gates on TRUST_PROXY not NODE_ENV - Docker image note: NODE_ENV=production no longer implies secure cookies - Security checklist: clarify TRUST_PROXY enables secure cookie + CSP + HSTS - dashboard.js route table: add /stream endpoint note - NODE_ENV env var table: correct description README.md: - qBittorrent availability: note red highlight when < 100% - Login side-effects: secure cookie gated on TRUST_PROXY not NODE_ENV
37 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
- UML Diagrams (PlantUML)
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
┌─────────────────────────────────────────────────────┐
│ Browser (SPA) │
│ ┌──────────┐ ┌──────────┐ ┌───────────────────┐ │
│ │ Login │ │Dashboard │ │ Status Panel │ │
│ │ Form │ │ Cards │ │ (Admin only) │ │
│ └────┬─────┘ └────┬─────┘ └───────┬───────────┘ │
│ │ │ │ │
└───────┼──────────────┼────────────────┼──────────────┘
│ POST /login │ GET /user- │ GET /status
│ │ downloads │
▼ ▼ ▼
┌─────────────────────────────────────────────────────┐
│ Express Server (:3001) │
│ ┌────────┐ ┌──────────┐ ┌────────┐ ┌────────────┐ │
│ │ Auth │ │Dashboard │ │ Emby │ │ Static │ │
│ │ Routes │ │ Routes │ │ Routes │ │ Files │ │
│ └────┬───┘ └────┬─────┘ └────┬───┘ └────────────┘ │
│ │ │ │ │
│ ┌────┴──────────┴────────────┴──────────────────┐ │
│ │ Utilities Layer │ │
│ │ ┌────────┐ ┌────────┐ ┌──────┐ ┌──────────┐ │ │
│ │ │ Poller │ │ Cache │ │Config│ │qBittorrent│ │ │
│ │ └───┬────┘ └────────┘ └──────┘ └──────────┘ │ │
│ └──────┼────────────────────────────────────────┘ │
└─────────┼────────────────────────────────────────────┘
│ HTTP/API calls
▼
┌──────────────────────────────────────────────────────┐
│ External Services │
│ ┌──────────┐ ┌────────┐ ┌────────┐ ┌────────────┐ │
│ │ SABnzbd │ │ Sonarr │ │ Radarr │ │qBittorrent │ │
│ │ (Usenet) │ │ (TV) │ │(Movie) │ │ (Torrent) │ │
│ └──────────┘ └────────┘ └────────┘ └────────────┘ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Emby / Jellyfin │ │
│ │ (Authentication + User DB) │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
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
│ ├── 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
│ ├── 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 |
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.
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 |
| Sonarr History | GET /api/v3/history |
pageSize=10 |
| 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) |
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):
1. Try Sonarr QUEUE match (by title substring)
→ resolve series via seriesMap (embedded in queue record)
→ extract user tag → check tag matches requesting user
2. Try Radarr QUEUE match (by title substring)
→ resolve movie via moviesMap (embedded in queue record)
→ extract user tag → check tag matches requesting user
3. Try Sonarr HISTORY match (by title substring)
→ resolve series via seriesMap (from queue) using seriesId
→ extract user tag → check tag matches requesting user
4. Try Radarr HISTORY match (by title substring)
→ resolve movie via moviesMap (from queue) using movieId
→ extract user tag → check tag matches requesting user
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 |
episodeInfo / movieInfo |
object | Full *arr queue/history record |
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 }
]
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
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Splash Screen│────▶│ Login Form │────▶│ Dashboard │
│ (on load) │ │ (if no │ │ (after auth) │
│ │ │ session) │ │ │
└──────────────┘ └──────────────┘ └──────────────┘
│
┌─────┴─────┐
│ Status │
│ Panel │
│ (admin) │
└───────────┘
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. |
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). |
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
Docker Compose
services:
sofarr:
image: docker.i3omb.com/sofarr:latest
container_name: sofarr
restart: unless-stopped
ports:
- "3001:3001"
environment:
- NODE_ENV=production
- DATA_DIR=/app/data
- COOKIE_SECRET=change-me-to-a-long-random-string
- TRUST_PROXY=1 # set if behind nginx/Traefik
- 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 # persists tokens.json and server.log
volumes:
sofarr-data:
Security hardening checklist
- Set
COOKIE_SECRET— enables HMAC-signed cookies, preventing client-side forgery. - Set
TRUST_PROXY=1when behind a reverse proxy — ensuresreq.secureistrueso thesecurecookie flag is enforced and HTTPS-upgrade CSP fires. - Mount a named volume for
DATA_DIR— token store and log file survive container recreates. - Use HTTPS — set
TRUST_PROXY=1to enable the CSPupgrade-insecure-requestsdirective, thesecurecookie flag, and HSTS (1-yearmaxAge). - 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 |
13. UML Diagrams (PlantUML)
All diagrams below are in PlantUML syntax. Render with any PlantUML tool or VS Code extension.
13.1 Component Diagram
13.2 Sequence Diagrams
- Authentication:
diagrams/seq-auth.puml - Dashboard Request:
diagrams/seq-dashboard.puml - Polling Cycle:
diagrams/seq-polling.puml
13.3 Class / Entity Diagrams
- Server Classes:
diagrams/class-server.puml - Data Model:
diagrams/class-data.puml
13.4 State Diagrams
- Frontend UI States:
diagrams/state-ui.puml - Poller States:
diagrams/state-poller.puml
13.5 Activity Diagram
- Download Matching:
diagrams/activity-matching.puml