# 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. [Diagrams (Mermaid)](#13-diagrams) --- ## 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 ```mermaid flowchart TB subgraph Browser["Browser (SPA)"] login["Login Form"] dash["Dashboard Cards"] status["Status Panel\n(Admin only)"] end subgraph Server["Express Server (:3001)"] auth_r["Auth Routes\n/api/auth"] dash_r["Dashboard Routes\n/api/dashboard"] emby_r["Emby Routes\n/api/emby"] static_f["Static Files\npublic/"] subgraph Utils["Utilities Layer"] poller["Poller"] cache["Cache"] config["Config"] qbt["qBittorrent"] end end subgraph Ext["External Services"] sab["SABnzbd\n(Usenet)"] sonarr["Sonarr\n(TV)"] radarr["Radarr\n(Movies)"] qbittorrent["qBittorrent\n(Torrent)"] emby["Emby / Jellyfin\n(Auth + User DB)"] end login -->|"POST /login"| auth_r dash -->|"GET /stream SSE\nGET /user-downloads"| dash_r status -->|"GET /status"| dash_r auth_r -->|"authenticate"| emby emby_r -->|"proxy"| emby dash_r --> Utils poller -->|"HTTP/API calls"| sab & sonarr & radarr qbt -->|"HTTP/API calls"| qbittorrent static_f -.->|"serve"| Browser ``` --- ## 2. Technology Stack ### Runtime & Framework | Layer | Technology | Purpose | |-------|-----------|------| | **Runtime** | Node.js 22 (Alpine) | Server runtime | | **Framework** | Express 4.x | HTTP server, routing, middleware | | **HTTP Client** | axios 1.x | External API communication | | **Frontend** | Vanilla JS + CSS | Single-page app, no build step | | **Containerisation** | Docker multi-stage (Alpine) | Production deployment | | **Logging** | Custom logger + `console.*` | File + stdout logging with levels | ### Security Middleware | Package | Purpose | |---------|--------| | `helmet` 7.x | HTTP security headers (CSP with per-request nonce, HSTS, referrer policy) | | `express-rate-limit` 7.x | 300 req/15 min general limiter; 10 failed attempts/15 min login limiter | | `cookie-parser` 1.x | Signed cookie support (HMAC via `COOKIE_SECRET`) | ### Auth & Session | Component | Technology | Details | |-----------|-----------|--------| | **Identity** | Emby API | `POST /Users/authenticatebyname` | | **Session cookie** | `httpOnly` + `sameSite: strict` | Signed when `COOKIE_SECRET` set | | **CSRF protection** | Double-submit cookie pattern | `csrf_token` cookie + `X-CSRF-Token` header | | **Token store** | JSON file (`DATA_DIR/tokens.json`) | Atomic writes, 31-day TTL, hourly pruning | ### Testing | Tool | Purpose | |------|---------| | `vitest` 4.x | Test runner (V8 coverage built-in) | | `supertest` 7.x | HTTP integration testing | | `nock` 14.x | HTTP interception at Node layer (works with CJS `require('axios')`) | --- ## 3. Directory Structure ``` sofarr/ ├── server/ # Backend application │ ├── index.js # Entry point: logging setup, server listen, poller start │ ├── app.js # Express app factory (imported by index.js and tests) │ ├── routes/ │ │ ├── auth.js # POST /login, GET /me, GET /csrf, POST /logout │ │ ├── dashboard.js # GET /user-downloads, /user-summary, /status, /cover-art │ │ ├── emby.js # Proxy routes to Emby API │ │ ├── sabnzbd.js # Proxy routes to SABnzbd API │ │ ├── sonarr.js # Proxy routes to Sonarr API │ │ ├── radarr.js # Proxy routes to Radarr API │ │ └── history.js # GET /api/history/recent — recently completed downloads │ ├── middleware/ │ │ ├── requireAuth.js # httpOnly cookie auth enforcement │ │ └── verifyCsrf.js # CSRF double-submit cookie validation │ └── utils/ │ ├── cache.js # MemoryCache class (Map + TTL + stats) │ ├── config.js # Multi-instance service configuration parser │ ├── historyFetcher.js # Fetch + cache Sonarr/Radarr history; event classification │ ├── logger.js # File logger (DATA_DIR/server.log) │ ├── poller.js # Background polling engine + timing │ ├── qbittorrent.js # qBittorrent client with auth + torrent mapping │ ├── sanitizeError.js # Redacts secrets from error messages before logging │ └── tokenStore.js # JSON file-backed Emby token store (atomic, TTL) ├── public/ # Static frontend (served by Express) │ ├── index.html # HTML shell: splash, login, dashboard │ ├── app.js # All frontend logic (auth, rendering, status) │ ├── style.css # Themes, layout, responsive design │ ├── favicon.ico # Multi-size favicon (16/32/48px) │ ├── favicon-32.png # 32px PNG favicon │ ├── favicon-192.png # 192px PNG (apple-touch-icon / PWA) │ └── images/ # Logo / splash screen assets ├── tests/ │ ├── README.md # Testing approach, design decisions, coverage targets │ ├── setup.js # Global setup: isolated DATA_DIR, rate-limit bypass │ ├── unit/ # Pure unit tests (no HTTP) │ └── integration/ # Supertest integration tests (nock for external HTTP) ├── docs/ │ ├── ARCHITECTURE.md # This document │ └── diagrams/ # PlantUML source files ├── .gitea/workflows/ │ ├── ci.yml # Security audit + test/coverage CI jobs │ ├── build-image.yml # Docker image build and push │ └── create-release.yml # Release tagging workflow ├── Dockerfile # Multi-stage production container image (node:22-alpine) ├── docker-compose.yaml # Example compose deployment ├── vitest.config.js # Test runner configuration with per-file coverage thresholds ├── package.json # Dependencies and scripts ├── .env.sample # Annotated environment variable template └── README.md # User-facing documentation ``` --- ## 4. Component Architecture ### 4.1 Application Factory (`server/app.js`) and Entry Point (`server/index.js`) **`server/app.js`** is a `createApp(options?)` factory that builds and returns the configured Express `app` instance. It is imported by both `server/index.js` (production) and the test suite. Keeping app creation separate from `app.listen()` means tests get a clean instance without triggering log-file setup, `process.exit()` calls, or the background poller. `createApp` responsibilities: - Configure `trust 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 (except `/stream` — GET) | SSE stream, aggregated download data, status, cover-art proxy | | `emby.js` | `/api/emby` | Yes (`requireAuth`) | Yes | Proxy to Emby API | | `sabnzbd.js` | `/api/sabnzbd` | Yes (`requireAuth`) | Yes | Proxy to SABnzbd API | | `sonarr.js` | `/api/sonarr` | Yes (`requireAuth`) | Yes | Proxy to Sonarr API | | `radarr.js` | `/api/radarr` | Yes (`requireAuth`) | Yes | Proxy to Radarr API | | `history.js` | `/api/history` | Yes (`requireAuth`) | No (GET only) | Recently completed downloads from Sonarr/Radarr history | **`requireAuth`** (`server/middleware/requireAuth.js`) reads the `emby_user` cookie (signed if `COOKIE_SECRET` is set) and attaches the parsed `{ id, name, isAdmin }` user to `req.user`. Returns `401` if the cookie is absent, tampered, or schema-invalid. **`verifyCsrf`** (`server/middleware/verifyCsrf.js`) implements the double-submit cookie pattern for all state-changing requests (`POST`, `PUT`, `PATCH`, `DELETE`). Compares the `csrf_token` cookie against the `X-CSRF-Token` request header using `crypto.timingSafeEqual`. Safe methods (`GET`, `HEAD`, `OPTIONS`) are exempt. Auth routes are mounted **before** this middleware (login/logout are exempt by design — `sameSite: strict` provides equivalent protection). > **Note:** The proxy routes (`emby`, `sabnzbd`, `sonarr`, `radarr`) exist for direct API access. The dashboard itself reads from the poller cache — it does not proxy through these routes. ### 4.3 Utility Modules **`config.js`** — Parses service instances from environment variables. Supports both JSON array format (`SONARR_INSTANCES=[{...}]`) and legacy single-instance format (`SONARR_URL` + `SONARR_API_KEY`). Each instance gets an `id` derived from `name` or index. **`cache.js`** — Singleton `MemoryCache` backed by a `Map`. Each entry has an expiration timestamp. Provides `get`, `set`, `invalidate`, `clear`, and `getStats` (returns per-key size, item count, TTL remaining). **`poller.js`** — Background polling engine. Fetches data from all configured service instances in parallel with per-task timing. Stores results in the cache with a configurable TTL. Can be disabled entirely (`POLL_INTERVAL=0`), in which case data is fetched on-demand. After each successful poll it notifies all registered SSE subscriber callbacks so connected clients receive data immediately. **`qbittorrent.js`** — `QBittorrentClient` class with cookie-based authentication, automatic re-auth on 403, and persistent client instances. Includes torrent-to-download mapping (`mapTorrentToDownload`) and formatting utilities (`formatBytes`, `formatSpeed`, `formatEta`). **`tokenStore.js`** — JSON file-backed store (`DATA_DIR/tokens.json`) for Emby `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. **`historyFetcher.js`** — Fetches history records from all Sonarr/Radarr instances for a configurable date window (`since`). Results are cached under `history:sonarr` / `history:radarr` for 5 minutes. Exports `classifySonarrEvent` / `classifyRadarrEvent` (returns `'imported'` | `'failed'` | `'other'`) and `invalidateHistoryCache`. **`logger.js`** — Simple file appender writing timestamped messages to `DATA_DIR/server.log`. --- ## 5. Data Flow ### 5.1 Polling Cycle Every `POLL_INTERVAL` ms (default 5000), the poller fetches from all services in parallel: | Task | API Call | Params | |------|----------|--------| | SABnzbd Queue | `GET /api?mode=queue` | `output=json` | | SABnzbd History | `GET /api?mode=history` | `limit=10` | | Sonarr Tags | `GET /api/v3/tag` | — | | Sonarr Queue | `GET /api/v3/queue` | `includeSeries=true`, `includeEpisode=true` | | Sonarr History | `GET /api/v3/history` | `pageSize=10`, `includeEpisode=true` | | Radarr Queue | `GET /api/v3/queue` | `includeMovie=true` | | Radarr History | `GET /api/v3/history` | `pageSize=10` | | Radarr Tags | `GET /api/v3/tag` | — | | qBittorrent | `GET /api/v2/torrents/info` | — | Results are stored in the cache under `poll:*` keys with a TTL of `POLL_INTERVAL × 3`. ### 5.2 SSE Stream When a browser opens `GET /api/dashboard/stream` (after authentication): 1. Server sets `Content-Type: text/event-stream`, disables buffering (`X-Accel-Buffering: no`) 2. Immediately builds and sends the first payload (same matching logic as below) 3. Registers a callback with the poller's `onPollComplete` subscriber set 4. After every subsequent poll cycle completes the callback fires, rebuilds the payload, and writes a `data:` SSE frame 5. A 25-second heartbeat comment (`: heartbeat`) keeps the connection alive through proxies 6. On client disconnect: deregisters callback, stops heartbeat, removes from active-client map The browser's native `EventSource` API handles reconnection automatically on network interruption. ### 5.3 Download Matching For each connected user the server: 1. Reads all `poll:*` keys from cache 2. Builds `seriesMap` and `moviesMap` from embedded objects in queue records 3. Builds `sonarrTagMap` and `radarrTagMap` from tag data 4. For each SABnzbd queue slot → tries to match against Sonarr/Radarr queue records by title 5. For each SABnzbd history slot → tries to match against Sonarr/Radarr history records 6. For each qBittorrent torrent → tries to match against Sonarr/Radarr queue, then history 7. For each match, resolves the series/movie, extracts the user tag, checks if it belongs to the requesting user 8. Returns 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 `TRUST_PROXY` is set (i.e. a TLS-terminating reverse proxy is in front) - 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) | | `history:sonarr` | `[record, ...]` — flat array with `_instanceUrl` / `_instanceName` | Sonarr history (5 min TTL, fetched on-demand by `/api/history/recent`) | | `history:radarr` | `[record, ...]` — flat array with `_instanceUrl` / `_instanceName` | Radarr history (5 min TTL, fetched on-demand by `/api/history/recent`) | ### TTL Strategy - **Polling enabled**: `POLL_INTERVAL × 3` — ensures data survives between polls even if one poll is slow - **Polling disabled**: `30000` ms — stale after 30s, next request triggers a fresh fetch ### Active Client Tracking SSE connections are tracked precisely: registered on connect, removed on disconnect. The admin status panel shows each connected user and how long they have been connected. The `type: 'sse'` field distinguishes SSE clients from any legacy HTTP clients. --- ## 8. Download Matching Pipeline The core logic in `dashboard.js` matches raw download client data to *arr service metadata to determine which user each download belongs to. ### Matching Strategy For each download item (SABnzbd slot or qBittorrent torrent): ```mermaid flowchart TD Start(["Download item"]) --> SQ{"Sonarr QUEUE\nmatch (title)"} SQ -->|yes| SQR["Resolve series via seriesMap\nextract user tag, check match"] SQ -->|no| RQ{"Radarr QUEUE\nmatch (title)"} RQ -->|yes| RQR["Resolve movie via moviesMap\nextract user tag, check match"] RQ -->|no| SH{"Sonarr HISTORY\nmatch (title)"} SH -->|yes| SHR["Resolve series via seriesId\nextract user tag, check match"] SH -->|no| RH{"Radarr HISTORY\nmatch (title)"} RH -->|yes| RHR["Resolve movie via movieId\nextract user tag, check match"] RH -->|no| Skip(["Skip - unmatched"]) SQR & RQR & SHR & RHR --> Tagged{"Tag matches\nrequesting user?"} Tagged -->|yes| Include(["Include in response"]) Tagged -->|no| Skip ``` ### Title Matching Matches are **bidirectional substring matches** (case-insensitive): ```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 | | `episodes` | `{season, episode, title}[]` | (Series only) Episodes covered by this download, sorted by season/episode. Single-episode downloads have one entry; series packs have multiple. Empty array if Sonarr has no episode data. | | `allTags` | string[] | All resolved tag labels on the series/movie | | `matchedUserTag` | string / null | Tag label matching the requesting user, or `null` | | `tagBadges` | `{label, matchedUser}[]` / undefined | (Admin `showAll` only) Each tag classified against full Emby user list | | `importIssues` | string[] / null | Import warning/error messages | | `downloadPath` | string / null | (Admin) Download client path | | `targetPath` | string / null | (Admin) *arr target path | | `arrLink` | string / null | (Admin) Link to *arr web UI | --- ## 9. API Reference ### `POST /api/auth/login` Authenticate a user via Emby. Rate-limited to **10 failed attempts per IP per 15 minutes**. **Request Body:** ```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/stream` Server-Sent Events stream. Opens a persistent connection that pushes download data on every poll cycle. **Query Parameters:** | Param | Type | Description | |-------|------|-------------| | `showAll` | `"true"` | (Admin) Include all users' downloads | **Response:** `Content-Type: text/event-stream` Each event is a `data:` frame containing JSON: ```json { "user": "Alice", "isAdmin": false, "downloads": [ /* download objects — same shape as /user-downloads */ ] } ``` The connection is kept alive with `: heartbeat` comments every 25 seconds. The browser's `EventSource` reconnects automatically on failure. --- ### `GET /api/dashboard/user-downloads` Fetch downloads for the authenticated user (single HTTP request, no streaming). **Query Parameters:** | Param | Type | Description | |-------|------|-------------| | `showAll` | `"true"` | (Admin) Show all users' downloads | **Response (200):** ```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", "type": "sse", "connectedAt": 1715817600000, "lastSeen": 1715817600000 } ] } ``` --- ### `GET /api/dashboard/user-summary` Admin-only per-user download counts (fetches live from APIs, not cached). **Response (200):** ```json [ { "username": "Alice", "seriesCount": 12, "movieCount": 5 } ] ``` --- ### `GET /api/history/recent` Returns recently completed (imported or failed) downloads from Sonarr/Radarr history for the authenticated user, filtered to the last `days` days. **Query Parameters:** | Param | Type | Default | Description | |-------|------|---------|-------------| | `days` | integer | `RECENT_COMPLETED_DAYS` env (default `7`) | How many days back to search. Capped at 90. | | `showAll` | `"true"` | — | (Admin) Return records for all tagged users, not just the current user | **Response (200):** ```json { "user": "Alice", "isAdmin": false, "days": 7, "history": [ { "type": "series", "outcome": "imported", "title": "Show.S01E01.720p", "seriesName": "My Show", "episodes": [ { "season": 1, "episode": 1, "title": "Pilot" } ], "coverArt": "https://…/poster.jpg", "completedAt": "2026-05-15T18:00:00.000Z", "quality": "720p", "instanceName": "Main Sonarr", "arrLink": "https://sonarr.example.com/series/my-show", "allTags": ["alice"], "matchedUserTag": "alice", "arrRecordId": 1234, "failureMessage": null } ] } ``` - `outcome` is `"imported"` or `"failed"`. Records with other event types (e.g. `grabbed`) are filtered out. - `episodes` is a sorted array of `{ season, episode, title }` objects. Single-episode downloads have one entry; series packs have multiple. `title` is `null` if not returned by Sonarr. Empty array if Sonarr has no episode data. - `failureMessage` is only included when the authenticated user is an admin and `outcome` is `"failed"`. - `arrRecordId` is only included for admin users. - Results are sorted newest first. - History data is cached server-side for 5 minutes (`history:sonarr` / `history:radarr` cache keys). --- ## 10. Frontend Architecture The frontend is a **vanilla JavaScript SPA** with no build step. All logic resides in `app.js`, styled by `style.css`, and structured by `index.html`. ### UI States ```mermaid stateDiagram-v2 [*] --> SplashScreen : Page load SplashScreen --> LoginForm : No session SplashScreen --> Dashboard : Valid session LoginForm --> Dashboard : Auth success Dashboard --> LoginForm : Logout state Dashboard { [*] --> ActiveDownloads ActiveDownloads --> ActiveDownloads : SSE update state StatusPanel { [*] --> Closed Closed --> Open : Click Status (admin) Open --> Closed : Click close Open --> Open : 5s refresh } } ``` ### Key Frontend Functions | Function | Purpose | |----------|---------| | `checkAuthentication()` | On load: check session → show dashboard or login | | `handleLogin()` | Authenticate, fade login → splash → dashboard | | `startSSE()` | Open `EventSource` to `/stream`; handles incoming data + first-message loading hide | | `stopSSE()` | Close `EventSource` and cancel reconnect timer | | `renderDownloads()` | Diff-based card rendering (create/update/remove) | | `createDownloadCard()` | Build DOM for a single download card; renders tag badges | | `updateDownloadCard()` | Update existing card in-place (progress, speed, etc.) | | `toggleStatusPanel()` | Show/hide admin status panel | | `renderStatusPanel()` | Build status HTML (server, polling, SSE clients, cache) | | `initThemeSwitcher()` | Light / Dark / Mono theme support | ### Themes Three CSS themes via `data-theme` attribute on ``: - **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) ### Live Push via SSE The dashboard receives updates via a persistent `EventSource` connection to `GET /api/dashboard/stream`. The server pushes a new `data:` event immediately after every poll cycle completes — there is no client-side timer. The browser's `EventSource` implementation handles reconnection automatically on network interruption. The status panel refreshes on a fixed 5-second timer and shows each SSE client with its connect duration. --- ## 11. Configuration ### Environment Variables #### Core | Variable | Required | Default | Description | |----------|:--------:|---------|-------------| | `PORT` | No | `3001` | Server listen port | | `NODE_ENV` | No | — | Set to `production` for production logging and startup validation. Does **not** control cookie `secure` flag or CSP `upgrade-insecure-requests` (both gated on `TRUST_PROXY`). | | `DATA_DIR` | No | `./data` | Directory for `tokens.json` and `server.log`. Must be writable by the server process. In Docker: `/app/data` (named volume). | | `COOKIE_SECRET` | No | — | If set, signs all session cookies with HMAC-SHA256. Strongly recommended in production. | | `TRUST_PROXY` | No | — | Express `trust proxy` setting. Set to `1` (or a hop count) when behind a reverse proxy (nginx, Traefik, etc.) so `req.ip` and `req.secure` are correct. | | `TLS_ENABLED` | No | `true` | Set to `false` to disable HTTPS and run plain HTTP (e.g. when TLS is terminated by a reverse proxy). | | `TLS_CERT` | No | `certs/snakeoil.crt` | Path to the TLS certificate file (PEM). Defaults to the bundled self-signed snakeoil certificate. | | `TLS_KEY` | No | `certs/snakeoil.key` | Path to the TLS private key file (PEM). Defaults to the bundled snakeoil key. | #### Emby | Variable | Required | Default | Description | |----------|:--------:|---------|-------------| | `EMBY_URL` | Yes | — | Emby/Jellyfin base URL (e.g. `https://emby.example.com`) | | `EMBY_API_KEY` | Yes | — | Emby API key (used by the poller to list users for tag badge classification) | #### Service Instances | Variable | Required | Default | Description | |----------|:--------:|---------|-------------| | `SONARR_INSTANCES` | Yes* | — | JSON array of Sonarr instances | | `SONARR_URL` | Yes* | — | Legacy: single Sonarr URL | | `SONARR_API_KEY` | Yes* | — | Legacy: single Sonarr API key | | `RADARR_INSTANCES` | Yes* | — | JSON array of Radarr instances | | `RADARR_URL` | Yes* | — | Legacy: single Radarr URL | | `RADARR_API_KEY` | Yes* | — | Legacy: single Radarr API key | | `SABNZBD_INSTANCES` | Yes* | — | JSON array of SABnzbd instances | | `SABNZBD_URL` | Yes* | — | Legacy: single SABnzbd URL | | `SABNZBD_API_KEY` | Yes* | — | Legacy: single SABnzbd API key | | `QBITTORRENT_INSTANCES` | No | — | JSON array of qBittorrent instances | \* Either `*_INSTANCES` (JSON array) or legacy `*_URL` + `*_API_KEY` format is required. #### Tuning | Variable | Required | Default | Description | |----------|:--------:|---------|-------------| | `POLL_INTERVAL` | No | `5000` | Poll interval in ms. Set to `0`, `off`, or `false` to disable background polling (on-demand mode). | | `RECENT_COMPLETED_DAYS` | No | `7` | Default lookback window (days) for `GET /api/history/recent`. Overridable per-request via `?days=`. Max 90. | | `LOG_LEVEL` | No | `info` | `debug`, `info`, `warn`, `error`, `silent` | ### Instance JSON Format ```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 production startup validation and logging - `DATA_DIR=/app/data` — token store and log file location - TLS is **enabled by default** using the bundled snakeoil self-signed certificate (`certs/snakeoil.crt`). Set `TLS_CERT`/`TLS_KEY` to your own certificate, or set `TLS_ENABLED=false` when terminating TLS at a reverse proxy. ### Docker Compose ```yaml services: sofarr: image: docker.i3omb.com/sofarr:latest container_name: sofarr restart: unless-stopped ports: - "3001:3001" # HTTPS by default (snakeoil cert if no TLS_CERT set) environment: - NODE_ENV=production - DATA_DIR=/app/data - COOKIE_SECRET=change-me-to-a-long-random-string # Option A: direct TLS (default). Supply your own cert/key: # - TLS_CERT=/app/certs/server.crt # - TLS_KEY=/app/certs/server.key # Option B: behind a TLS-terminating reverse proxy: # - TLS_ENABLED=false # - TRUST_PROXY=1 - EMBY_URL=https://emby.example.com - EMBY_API_KEY=your-emby-api-key - SONARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}] - RADARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}] - SABNZBD_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}] - QBITTORRENT_INSTANCES=[{"name":"main","url":"...","username":"...","password":"..."}] - POLL_INTERVAL=5000 - LOG_LEVEL=info volumes: - sofarr-data:/app/data # Uncomment to supply your own certificate (Option A): # - /path/to/server.crt:/app/certs/server.crt:ro # - /path/to/server.key:/app/certs/server.key:ro volumes: sofarr-data: ``` ### Security hardening checklist - **Use HTTPS** — TLS is on by default (snakeoil cert). Supply `TLS_CERT`/`TLS_KEY` pointing to a CA-signed certificate for trusted HTTPS. Alternatively terminate TLS at a reverse proxy and set `TLS_ENABLED=false` + `TRUST_PROXY=1`. - **Set `COOKIE_SECRET`** — enables HMAC-signed cookies, preventing client-side forgery. - **Set `TRUST_PROXY=1`** only when a TLS-terminating reverse proxy sits in front — ensures `req.secure` is correct and the CSP `upgrade-insecure-requests` + `secure` cookie flag fire correctly. - **Mount a named volume** for `DATA_DIR` — token store and log file survive container recreates. - **Rate limiting** — the login endpoint is limited to 10 failed attempts per IP per 15 minutes; all API endpoints share a 300 req/15 min window. Set `TRUST_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 | > **Diagrams** are written in Mermaid and render natively in Gitea — no CI workflow required. See [Section 13](#13-diagrams). --- ## 13. Diagrams All diagrams are written in [Mermaid](https://mermaid.js.org/) and render natively in Gitea and GitHub markdown. ### 13.1 Component Diagram ```mermaid graph TB subgraph Browser html[index.html] appjs[app.js] css[style.css] html -->|loads| appjs html -->|loads| css end subgraph Express Server entry[index.js\nEntry Point] appfactory[app.js\ncreateApp factory] subgraph Middleware hm[helmet\nCSP nonce + HSTS] rl[express-rate-limit\nAPI + login] cp[cookie-parser\nsigned cookies] ej[express.json\n64kb limit] es[express.static] requireauth[requireAuth.js] verifycsrf[verifyCsrf.js\ndouble-submit] end subgraph Routes auth[auth.js\n/api/auth\npre-CSRF] dashboard[dashboard.js\n/api/dashboard\n+SSE /stream] emby_r[emby.js\n/api/emby] sab_r[sabnzbd.js\n/api/sabnzbd] sonarr_r[sonarr.js\n/api/sonarr] radarr_r[radarr.js\n/api/radarr] history_r[history.js\n/api/history] end subgraph Utilities poller[poller.js] cache[cache.js\nMemoryCache] config[config.js] qbt[qbittorrent.js\nQBittorrentClient] tokenstore[tokenStore.js\ntokens.json] sanitize[sanitizeError.js] logger[logger.js] historyfetcher[historyFetcher.js] end entry --> appfactory entry --> es entry --> poller appfactory --> hm & rl & cp & ej appfactory -->|pre-CSRF| auth appfactory --> verifycsrf appfactory --> dashboard & emby_r & sab_r & sonarr_r & radarr_r & history_r dashboard & emby_r & sab_r & sonarr_r & radarr_r & history_r --> requireauth auth --> tokenstore dashboard --> cache & poller & config & qbt history_r --> cache & config & historyfetcher historyfetcher --> cache & config poller --> cache & config & qbt & logger qbt --> config & logger auth & dashboard -.-> sanitize end subgraph External Services emby[Emby / Jellyfin] sab[SABnzbd] sonarr[Sonarr] radarr[Radarr] qbit[qBittorrent] end auth --> emby dashboard --> emby poller --> sab & sonarr & radarr qbt --> qbit emby_r --> emby sab_r --> sab sonarr_r --> sonarr radarr_r --> radarr appjs -->|POST /login, GET /me, GET /csrf, POST /logout| auth appjs -->|GET /stream SSE, GET /user-downloads, GET /status| dashboard es -->|serve static| html ``` ### 13.2 Authentication Sequence ```mermaid sequenceDiagram actor User participant Browser as Browser (app.js) participant Auth as Express /api/auth participant Tokens as TokenStore (tokens.json) participant Emby as Emby Server rect rgb(240,240,255) Note over Browser,Auth: Page Load Browser->>Auth: GET /api/auth/me Auth->>Auth: Read emby_user cookie (signed if COOKIE_SECRET) alt Cookie valid Auth-->>Browser: { authenticated: true, user } Browser->>Auth: GET /api/auth/csrf Auth-->>Browser: { csrfToken } + Set csrf_token cookie Browser->>Browser: store csrfToken in memory Browser->>Browser: showDashboard() + startSSE() else No cookie / tampered Auth-->>Browser: { authenticated: false } Browser->>Browser: showLogin() end end rect rgb(240,255,240) Note over Browser,Emby: Login User->>Browser: Enter credentials (+ rememberMe) Browser->>Auth: POST /api/auth/login Note right of Auth: Rate limit: max 10 failed attempts per IP / 15 min Auth->>Emby: POST /Users/authenticatebyname (DeviceId = sha256(username)[0:16]) alt Valid credentials Emby-->>Auth: { User.Id, AccessToken } Auth->>Emby: GET /Users/{id} Emby-->>Auth: { Name, Policy.IsAdministrator } Auth->>Tokens: storeToken(userId, AccessToken) Note right of Tokens: Server-side only, 31-day TTL, atomic write Auth->>Auth: Set emby_user cookie (httpOnly, sameSite=strict, secure if TRUST_PROXY) Auth->>Auth: Set csrf_token cookie (httpOnly=false, sameSite=strict) Auth-->>Browser: { success: true, user, csrfToken } Browser->>Browser: showDashboard() + startSSE() else Invalid credentials Emby-->>Auth: 401 Auth-->>Browser: { success: false, error } end end rect rgb(255,245,230) Note over Browser,Auth: Logout User->>Browser: Click Logout Browser->>Browser: stopSSE() Browser->>Auth: POST /api/auth/logout Auth->>Tokens: getToken(userId) Tokens-->>Auth: { accessToken } Auth->>Emby: POST /Sessions/Logout Auth->>Tokens: clearToken(userId) Auth->>Auth: clearCookie(emby_user, csrf_token) Auth-->>Browser: { success: true } Browser->>Browser: showLogin() end ``` ### 13.3 Dashboard SSE Stream Sequence ```mermaid sequenceDiagram actor User participant Browser as Browser (app.js) participant Dashboard as Express /api/dashboard participant Cache as MemoryCache participant Poller participant Ext as External Services User->>Browser: Login success / valid session Browser->>Dashboard: GET /api/dashboard/stream (EventSource) Dashboard->>Dashboard: requireAuth: extract user/isAdmin Dashboard->>Dashboard: Set Content-Type: text/event-stream, register in activeClients opt Polling disabled AND cache empty Dashboard->>Poller: pollAllServices() Poller->>Ext: Parallel API calls Ext-->>Poller: Raw data Poller->>Cache: set poll:* keys (TTL=30s) end Dashboard->>Cache: get all poll:* keys Dashboard->>Dashboard: Build maps, match downloads, extractUserTag / buildTagBadges Dashboard-->>Browser: data: { user, isAdmin, downloads } Browser->>Browser: hideLoading() + renderDownloads() loop Every poll cycle Poller->>Poller: pollAllServices() complete Poller->>Dashboard: onPollComplete callback fires Dashboard->>Cache: get all poll:* keys Dashboard->>Dashboard: Rebuild payload Dashboard-->>Browser: data: { user, isAdmin, downloads } Browser->>Browser: renderDownloads() diff-based end Note over Dashboard,Browser: : heartbeat every 25s keeps connection alive User->>Browser: Close tab / logout Browser->>Dashboard: TCP close (req close event) Dashboard->>Dashboard: offPollComplete(cb), clearInterval(heartbeat), delete activeClients[key] ``` ### 13.4 Background Polling Cycle ```mermaid sequenceDiagram participant Entry as index.js (startup) participant Poller participant Config participant SAB as SABnzbd (per instance) participant Sonarr as Sonarr (per instance) participant Radarr as Radarr (per instance) participant QBT as qBittorrent Client participant Cache as MemoryCache Entry->>Poller: startPoller() alt POLL_INTERVAL > 0 Poller->>Poller: pollAllServices() immediate Poller->>Poller: setInterval(pollAllServices, POLL_INTERVAL) else POLL_INTERVAL = 0 Poller-->>Entry: on-demand mode end Note over Poller: Each poll cycle Poller->>Poller: polling flag check (skip if concurrent) Poller->>Poller: polling = true Poller->>Config: getSABnzbdInstances() / getSonarrInstances() / getRadarrInstances() Config-->>Poller: instance configs Note over Poller,Cache: All 9 fetches run in parallel via Promise.all, each wrapped in timed() Poller->>SAB: GET /api?mode=queue SAB-->>Poller: { queue: { slots, status, speed } } Poller->>SAB: GET /api?mode=history&limit=10 SAB-->>Poller: { history: { slots } } Poller->>Sonarr: GET /api/v3/tag + queue + history Sonarr-->>Poller: tags, queue records (includeSeries), history Poller->>Radarr: GET /api/v3/tag + queue + history Radarr-->>Poller: tags, queue records (includeMovie), history Poller->>QBT: getTorrents() QBT-->>Poller: [{ name, progress, ... }] Poller->>Poller: Record per-task timings: lastPollTimings = { totalMs, timestamp, tasks } Poller->>Cache: set poll:* keys (TTL = POLL_INTERVAL x 3) Poller->>Poller: Notify SSE subscribers (forEach cb()) Poller->>Poller: polling = false ``` ### 13.5 Server Class Diagram ```mermaid classDiagram class EntryPoint["index.js (EntryPoint)"] { +startPoller() +app.listen() setupLogging() serveStatic() } class createApp["app.js (createApp factory)"] { +createApp(skipRateLimits?) Express mountHelmet() mountRateLimiters() mountRoutes() mountErrorHandler() } class AuthRouter["auth.js (Router)"] { +POST /login +GET /me +GET /csrf +POST /logout authenticateViaEmby() issueCookies() revokeToken() } class DashboardRouter["dashboard.js (Router)"] { -activeClients Map +GET /stream SSE +GET /user-downloads +GET /user-summary +GET /status +GET /cover-art buildDownloadPayload() extractUserTag() buildTagBadges() getEmbyUsers() } class RequireAuth["requireAuth.js (Middleware)"] { +requireAuth(req, res, next) readCookie() validateSchema() } class VerifyCsrf["verifyCsrf.js (Middleware)"] { +verifyCsrf(req, res, next) timingSafeEqual() } class MemoryCache { -store Map +get(key) any +set(key, value, ttlMs) +invalidate(key) +clear() +getStats() CacheStats } class Poller { -POLL_INTERVAL number -polling boolean -subscribers Set +startPoller() +stopPoller() +pollAllServices() +onPollComplete(cb) +offPollComplete(cb) +getLastPollTimings() PollTimings } class Config { +getSABnzbdInstances() Instance[] +getSonarrInstances() Instance[] +getRadarrInstances() Instance[] +getQbittorrentInstances() Instance[] } class QBittorrentClient { -url string -authCookie string +login() bool +getTorrents() Torrent[] +makeRequest(endpoint) } class TokenStore { -STORE_PATH string -TOKEN_TTL_MS 31days +storeToken(userId, token) +getToken(userId) +clearToken(userId) atomicWrite() pruneExpired() } class SanitizeError { +sanitizeError(err) string redactQueryParams() redactAuthHeaders() } EntryPoint --> createApp : createApp() EntryPoint --> Poller : startPoller() createApp --> AuthRouter : mount pre-CSRF createApp --> VerifyCsrf : apply to /api createApp --> DashboardRouter DashboardRouter --> RequireAuth DashboardRouter --> MemoryCache DashboardRouter --> Poller DashboardRouter --> Config DashboardRouter ..> SanitizeError AuthRouter --> TokenStore AuthRouter ..> SanitizeError Poller --> MemoryCache Poller --> Config Poller --> QBittorrentClient QBittorrentClient --> Config ``` ### 13.6 Data Model Diagram ```mermaid classDiagram class Download { +type series|movie|torrent +title string +coverArt string +status string +progress string +size string +mb string +mbmissing string +speed string +eta string +seriesName string +movieName string +allTags string[] +matchedUserTag string +tagBadges TagBadge[] +importIssues string[] +downloadPath string +targetPath string +arrLink string +seeds number +peers number +availability string +hash string +completedAt string } class TagBadge { +label string +matchedUser string } class APIResponse { +user string +isAdmin boolean +downloads Download[] } class SSEEvent { +user string +isAdmin boolean +downloads Download[] } class StatusResponse { +server ServerInfo +polling PollingInfo +cache CacheStats +clients ClientInfo[] } class SessionCookie { +id string +name string +isAdmin boolean } class SABnzbdQueueSlot { +filename string +percentage string +mb string +mbmissing string +timeleft string +status string } class qBittorrentTorrent { +name string +hash string +progress float +state string +dlspeed number +eta number +num_seeds number +num_leechs number +availability number } class SonarrQueueRecord { +seriesId number +series SonarrSeries +title string +trackedDownloadStatus string +statusMessages StatusMessage[] } class RadarrQueueRecord { +movieId number +movie RadarrMovie +title string +trackedDownloadStatus string +statusMessages StatusMessage[] } APIResponse "1" *-- "many" Download SSEEvent "1" *-- "many" Download Download "1" *-- "many" TagBadge SABnzbdQueueSlot ..> Download : matched and transformed qBittorrentTorrent ..> Download : mapTorrentToDownload() SonarrQueueRecord ..> Download : coverArt, seriesName, tags RadarrQueueRecord ..> Download : coverArt, movieName, tags ``` ### 13.7 Frontend UI State Diagram ```mermaid stateDiagram-v2 [*] --> SplashScreen : Page load SplashScreen --> CheckAuth : checkAuthentication() state CheckAuth <> CheckAuth --> LoginForm : No session CheckAuth --> Dashboard : Valid session state LoginForm { [*] --> Idle Idle --> Submitting : Submit form Submitting --> Error : Auth failed Error --> Submitting : Re-submit Submitting --> [*] : Auth success } LoginForm --> Dashboard : Auth success (fade transition) state Dashboard { [*] --> Rendering Rendering --> Rendering : SSE message triggers renderDownloads() Rendering --> Rendering : Theme change state SSEConnection { [*] --> Connecting Connecting --> Connected : First message Connected --> Reconnecting : Connection lost Reconnecting --> Connected : Auto-reconnect Connected --> Connecting : showAll toggled } state StatusPanel { [*] --> Closed Closed --> Open : Click Status (admin) Open --> Closed : Click close Open --> Open : 5s timer refresh } } Dashboard --> LoginForm : Logout (stopSSE) ``` ### 13.8 Poller State Diagram ```mermaid stateDiagram-v2 [*] --> CheckConfig : startPoller() state CheckConfig <> CheckConfig --> Disabled : POLL_INTERVAL = 0 CheckConfig --> Idle : POLL_INTERVAL > 0 state Disabled { [*] --> OnDemand OnDemand : No background timer. Data fetched when dashboard request finds empty cache. } Disabled --> Polling : dashboard triggers pollAllServices() Polling --> Disabled : Poll complete (on-demand) Idle --> Polling : setInterval fires or immediate first poll state Polling { [*] --> Locked Locked : polling = true Locked --> Fetching Fetching --> Storing : All promises resolved Fetching --> HandleError : Per-service error (caught) Storing --> Notifying : Cache updated, TTL = POLL_INTERVAL x 3 Notifying : Notify SSE subscribers Notifying --> Done Done : polling = false Done --> [*] } state HandleError { [*] --> LogError LogError : Log error, polling = false } Polling --> Idle : Poll complete HandleError --> Idle : Next interval state ConcurrentSkip { [*] --> Skip Skip : polling === true, skip cycle } Idle --> ConcurrentSkip : Interval fires while previous still running ConcurrentSkip --> Idle : Log skip ``` ### 13.9 Download Matching Flow ```mermaid flowchart TD A([Start: user request]) --> B[Read all poll:* keys from MemoryCache] B --> C[Build seriesMap, moviesMap\nsonarrTagMap, radarrTagMap] C --> D{showAll?} D -->|yes| E[Fetch Emby user list\ncached 60s → embyUserMap] D -->|no| F E --> F[userDownloads = empty array] F --> G[/SABnzbd queue slots/] G --> H{Matches Sonarr queue?} H -->|yes| I[Resolve series\nextractAllTags + extractUserTag] I --> J{showAll + anyTag\nor matchedUserTag?} J -->|yes| K[Build Download object\nAdd tagBadges if showAll\nAdd importIssues, admin fields] K --> L[Push to userDownloads] H --> M{Matches Radarr queue?} M -->|yes| N[Resolve movie\nextractAllTags + extractUserTag] N --> J L --> G G --> O[/SABnzbd history slots/] O --> P{Matches Sonarr history?} P -->|yes| Q[Resolve series\nBuild Download type=series\nAdd completedAt] Q --> R{showAll+anyTag\nor matchedUserTag?} R -->|yes| S[Push to userDownloads] P --> T{Matches Radarr history?} T -->|yes| U[Resolve movie\nBuild Download type=movie\nAdd completedAt] U --> R S --> O O --> V[/qBittorrent torrents/] V --> W{Matches Sonarr queue?} W -->|yes| X[mapTorrentToDownload\n+ enrich with series] X --> Y{Tag matches?} Y -->|yes| Z[Push to userDownloads] W --> AA{Matches Radarr queue?} AA -->|yes| AB[mapTorrentToDownload\n+ enrich with movie] AB --> Y AA --> AC{Matches Sonarr history?} AC -->|yes| AD[Resolve series via seriesMap] AD --> Y AC --> AE{Matches Radarr history?} AE -->|yes| AF[Resolve movie via moviesMap] AF --> Y AE -->|no| AG[Skip - unmatched torrent] Z --> V AG --> V V --> AH([Return JSON\nuser, isAdmin, downloads]) style K fill:#d4edda style Q fill:#d4edda style U fill:#d4edda style X fill:#d4edda style AB fill:#d4edda style AD fill:#d4edda style AF fill:#d4edda style AG fill:#f8d7da ```