# 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](#1-system-overview) 2. [Technology Stack](#2-technology-stack) 3. [Directory Structure](#3-directory-structure) 4. [Component Architecture](#4-component-architecture) 5. [Data Flow](#5-data-flow) 6. [Authentication & Authorisation](#6-authentication--authorisation) 7. [Background Polling & Caching](#7-background-polling--caching) 8. [Download Matching Pipeline](#8-download-matching-pipeline) 9. [API Reference](#9-api-reference) 10. [Frontend Architecture](#10-frontend-architecture) 11. [Configuration](#11-configuration) 12. [Deployment](#12-deployment) 13. [UML Diagrams (PlantUML)](#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.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 `AccessToken`s. 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` | 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`, 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): ```javascript 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:** ```json { "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):** ```json { "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):** ```json { "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):** ```json { "authenticated": true, "user": { "id": "string", "name": "string", "isAdmin": false } } ``` **Response (not authenticated):** ```json { "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):** ```json { "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):** ```json { "user": "string", "isAdmin": true, "downloads": [ /* download objects */ ] } ``` --- ### `GET /api/dashboard/status` Admin-only server status. **Response (200):** ```json { "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):** ```json [ { "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 ``: - **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 ```json [ { "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 ```yaml 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`](diagrams/component.puml) ### 13.2 Sequence Diagrams - **Authentication**: [`diagrams/seq-auth.puml`](diagrams/seq-auth.puml) - **Dashboard Request**: [`diagrams/seq-dashboard.puml`](diagrams/seq-dashboard.puml) - **Polling Cycle**: [`diagrams/seq-polling.puml`](diagrams/seq-polling.puml) ### 13.3 Class / Entity Diagrams - **Server Classes**: [`diagrams/class-server.puml`](diagrams/class-server.puml) - **Data Model**: [`diagrams/class-data.puml`](diagrams/class-data.puml) ### 13.4 State Diagrams - **Frontend UI States**: [`diagrams/state-ui.puml`](diagrams/state-ui.puml) - **Poller States**: [`diagrams/state-poller.puml`](diagrams/state-poller.puml) ### 13.5 Activity Diagram - **Download Matching**: [`diagrams/activity-matching.puml`](diagrams/activity-matching.puml)