# 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` |
| Sonarr History | `GET /api/v3/history` | `pageSize=10` |
| Radarr Queue | `GET /api/v3/queue` | `includeMovie=true` |
| Radarr History | `GET /api/v3/history` | `pageSize=10` |
| Radarr Tags | `GET /api/v3/tag` | — |
| qBittorrent | `GET /api/v2/torrents/info` | — |

Results are stored in the cache under `poll:*` keys with a TTL of `POLL_INTERVAL × 3`.

### 5.2 SSE Stream

When a browser opens `GET /api/dashboard/stream` (after authentication):

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<lowerName, displayName>` | 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 |
| `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/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",
      "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.
- `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 `<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)

### 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\nGET /me\nGET /csrf\nPOST /logout| auth
    appjs -->|GET /stream SSE\nGET /user-downloads\nGET /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\nattempts per IP / 15 min
        Auth->>Emby: POST /Users/authenticatebyname\nDeviceId = 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\n31-day TTL, atomic write
            Auth->>Auth: Set emby_user cookie\nhttpOnly, sameSite=strict\nsecure (if TRUST_PROXY)\nrememberMe → Max-Age 30d
            Auth->>Auth: Set csrf_token cookie\nhttpOnly=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\nRegister 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\nextractUserTag / 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)\nclearInterval(heartbeat)\ndelete 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\nlastPollTimings = { totalMs, timestamp, tasks }
    Poller->>Cache: set poll:* keys (TTL = POLL_INTERVAL × 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 <<choice>>
    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\n(fade transition)

    state Dashboard {
        [*] --> Rendering
        Rendering --> Rendering : SSE message → 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 <<choice>>
    CheckConfig --> Disabled : POLL_INTERVAL = 0
    CheckConfig --> Idle : POLL_INTERVAL > 0

    state Disabled {
        [*] --> OnDemand
        OnDemand : No background timer.\nData fetched when dashboard\nrequest finds empty cache.
    }

    Disabled --> Polling : dashboard triggers pollAllServices()
    Polling --> Disabled : Poll complete (on-demand)

    Idle --> Polling : setInterval fires\nor 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\nTTL = POLL_INTERVAL × 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\nprevious 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
```
