Files
sofarr/docs/ARCHITECTURE.md
Gronod 8c829f9651
Some checks failed
Build and Push Docker Image / build (push) Successful in 35s
CI / Security audit (push) Successful in 58s
CI / Tests & coverage (push) Failing after 1m5s
docs: audit and update all documentation to reflect current codebase
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
2026-05-17 08:05:08 +01:00

35 KiB
Raw Blame History

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

  1. System Overview
  2. Technology Stack
  3. Directory Structure
  4. Component Architecture
  5. Data Flow
  6. Authentication & Authorisation
  7. Background Polling & Caching
  8. Download Matching Pipeline
  9. API Reference
  10. Frontend Architecture
  11. Configuration
  12. Deployment
  13. 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:

  1. Authenticating users against an Emby/Jellyfin media server.
  2. Aggregating download data from multiple *arr service instances and download clients.
  3. Filtering downloads by user — each user only sees media tagged with their username in Sonarr/Radarr.
  4. 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 proxy from TRUST_PROXY env var
  • Apply helmet with a per-request CSP nonce (crypto.randomBytes(16)) for inline styles/scripts
  • Add Permissions-Policy header
  • Apply the general API rate limiter (300 req / 15 min per IP)
  • Mount cookie-parser (signed when COOKIE_SECRET is set)
  • Mount express.json (64 KB body limit)
  • Expose /health and /ready endpoints (no auth, no rate limit)
  • Mount /api/auth routes before CSRF middleware (login/logout are exempt)
  • Mount verifyCsrf for all subsequent /api routes
  • Mount remaining route modules under /api/*
  • Register global error handler (500 with sanitized message)

server/index.js entry point responsibilities:

  • Load .env via dotenv
  • Configure structured logging (LOG_LEVEL) with console.* redirection to both stdout and DATA_DIR/server.log
  • Call createApp(), serve public/ as static files, start app.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.jsQBittorrentClient 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:

  1. Read all poll:* keys from cache
  2. Build seriesMap and moviesMap from embedded objects in queue records
  3. Build sonarrTagMap and radarrTagMap from tag data
  4. For each SABnzbd queue slot → try to match against Sonarr/Radarr queue records by title
  5. For each SABnzbd history slot → try to match against Sonarr/Radarr history records
  6. For each qBittorrent torrent → try to match against Sonarr/Radarr queue, then history
  7. For each match, resolve the series/movie, extract the user tag, check if it belongs to the requesting user
  8. Return only the user's downloads (or all, if admin with showAll=true)

6. Authentication & Authorisation

Flow

  1. User submits credentials (+ optional rememberMe) via the login form
  2. Backend applies the login rate limiter (max 10 failed attempts per IP per 15-minute window; successful requests don't count)
  3. Backend calls Emby POST /Users/authenticatebyname using a deterministic DeviceId derived from the username — this causes Emby to reuse the existing session rather than creating a new one on every login
  4. On success, fetches full user profile (GET /Users/{id}) to determine admin status
  5. Stores the Emby AccessToken in tokenStore (server-side, never sent to the client)
  6. Sets an httpOnly emby_user cookie containing only { id, name, isAdmin }:
    • rememberMe: true → persistent cookie, Max-Age 30 days
    • rememberMe: false → session cookie (no Max-Age; expires when browser closes)
    • secure flag enabled when NODE_ENV=production
    • Signed with HMAC when COOKIE_SECRET is set
  7. Issues a csrf_token cookie (httpOnly: false so JS can read it) containing a 32-byte random hex token
  8. Returns { success: true, user, csrfToken } — the SPA stores csrfToken in memory and sends it as X-CSRF-Token on all subsequent state-changing requests
  9. All subsequent dashboard requests read the emby_user cookie for identity via requireAuth

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:

  1. Exact match: tag label (lowercased) === username (lowercased)
  2. 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: 30000 ms — 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_user cookie: httpOnly, sameSite: strict, secure in production, signed if COOKIE_SECRET is set. Payload: { id, name, isAdmin }. The Emby AccessToken is never included.
  • Sets csrf_token cookie: httpOnly: false (JS-readable), same security flags. 64-char hex.
  • Stores the Emby AccessToken server-side in tokenStore (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 showAll view: all tags on the download are rendered using tagBadges[]:
    • 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:

  1. deps stage — runs npm ci --omit=dev to install only production dependencies.
  2. runtime stage — copies node_modules, server/, public/, and package.json. Runs as the built-in non-root node user (UID 1000). /app/data is owned by node for writable token store and logs.

Key environment variables set in the image:

  • NODE_ENV=production — enables secure cookies and HTTPS upgrade CSP directive
  • DATA_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=1 when behind a reverse proxy — ensures req.secure is true so the secure cookie 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-requests in production and the HSTS header is set with a 1-year maxAge.
  • 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_PROXY correctly 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

See diagrams/component.puml

13.2 Sequence Diagrams

13.3 Class / Entity Diagrams

13.4 State Diagrams

13.5 Activity Diagram