ARCHITECTURE.md: - Node version: 18+ → 22 (Alpine) - Tech stack: add helmet, express-rate-limit, cookie-parser, testing tools - Directory structure: add server/app.js, verifyCsrf.js, tokenStore.js, sanitizeError.js, tests/, docs/, .gitea/workflows/, vitest.config.js - §4.1: document app.js factory (createApp) vs index.js entry point; CSP nonce, rate limiters, CSRF middleware, trust proxy - §4.2: add CSRF Required column; document verifyCsrf; fix auth note - §4.3: add tokenStore.js and sanitizeError.js descriptions - §6 Auth flow: add rememberMe, rate limiter, stable DeviceId, server-side token store, CSRF token issuance, correct cookie TTL (session/30d not 24h) - §9 API: add csrfToken to login response, rememberMe field, 400/429 codes; add GET /api/auth/csrf endpoint; fix /me response; fix /logout CSRF note - §11 Config: add DATA_DIR, COOKIE_SECRET, TRUST_PROXY, NODE_ENV; split into Core / Emby / Service Instances / Tuning sections - §12 Deployment: update Dockerfile description to multi-stage node:22-alpine; add COOKIE_SECRET, TRUST_PROXY, named volume to compose example; add security hardening checklist; add CI/CD table diagrams/seq-auth.puml: - Add TokenStore participant - Add rememberMe, CSRF token issuance, stable DeviceId note - Add login rate limiter note - Add GET /csrf refresh flow - Add server-side token revocation on logout diagrams/class-server.puml: - Add app.js createApp() factory class - Add verifyCsrf middleware class - Add TokenStore and SanitizeError utility classes - Update auth.js routes (add GET /csrf) - Fix relationships: entry → appfn → routes diagrams/component.puml: - Add app.js factory component - Add helmet, express-rate-limit components - Add verifyCsrf middleware component - Add tokenStore.js and sanitizeError.js utility components - Fix wiring: entry → createApp() → mounts routes Dockerfile: - Fix stale comments referencing better-sqlite3 and SQLite server/routes/auth.js: - Fix stale comment: SQLite-backed → JSON file-backed
35 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 | 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 per dashboard request.
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 Dashboard Request
When a user requests /api/dashboard/user-downloads:
- Read all
poll:*keys from cache - Build
seriesMapandmoviesMapfrom embedded objects in queue records - Build
sonarrTagMapandradarrTagMapfrom tag data - For each SABnzbd queue slot → try to match against Sonarr/Radarr queue records by title
- For each SABnzbd history slot → try to match against Sonarr/Radarr history records
- For each qBittorrent torrent → try to match against Sonarr/Radarr queue, then history
- For each match, resolve the series/movie, extract the user tag, check if it belongs to the requesting user
- Return 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 whenNODE_ENV=production- 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
Each dashboard request reports the client's refresh rate. The server tracks active clients in a Map<username, { user, refreshRateMs, lastSeen }>, pruning entries older than 30 seconds. This data powers the admin status panel's "effective refresh mode" display.
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/user-downloads
Fetch downloads for the authenticated user.
Query Parameters:
| Param | Type | Description |
|---|---|---|
showAll |
"true" |
(Admin) Show all users' downloads |
refreshRate |
number (ms) | Client's current refresh rate for tracking |
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", "refreshRateMs": 5000, "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 |
fetchUserDownloads() |
GET /user-downloads, update state, re-render |
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, cache, clients) |
startAutoRefresh() |
Start periodic fetchUserDownloads |
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)
Auto-Refresh
The dashboard polls the server at the user-selected interval (1s, 5s, 10s, or Off). The status panel auto-refreshes in sync. Client refresh rate is sent to the server on each request for active-client tracking.
11. Configuration
Environment Variables
Core
| Variable | Required | Default | Description |
|---|---|---|---|
PORT |
No | 3001 |
Server listen port |
NODE_ENV |
No | — | Set to production to enable secure cookies and HTTPS upgrades |
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 secure cookies and HTTPS upgrade CSP directiveDATA_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 — the CSP includes
upgrade-insecure-requestsin production and the HSTS header is set with a 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