1468 lines
55 KiB
Markdown
1468 lines
55 KiB
Markdown
# 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
|
||
```
|