docs: comprehensive architecture documentation with PlantUML diagrams

- docs/ARCHITECTURE.md: full system overview, technology stack, directory
  structure, component architecture, data flow, auth, polling/caching,
  download matching pipeline, API reference, frontend architecture,
  configuration, deployment guide
- docs/diagrams/component.puml: system component diagram
- docs/diagrams/seq-auth.puml: authentication sequence diagram
- docs/diagrams/seq-dashboard.puml: dashboard request sequence diagram
- docs/diagrams/seq-polling.puml: background polling cycle sequence
- docs/diagrams/class-server.puml: server-side class/module diagram
- docs/diagrams/class-data.puml: data model / entity diagram
- docs/diagrams/state-ui.puml: frontend UI state diagram
- docs/diagrams/state-poller.puml: poller state diagram
- docs/diagrams/activity-matching.puml: download matching activity diagram
This commit is contained in:
2026-05-16 00:30:38 +01:00
parent 0c8d5d8a4a
commit e97bd3c67b
10 changed files with 1634 additions and 0 deletions

609
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,609 @@
# sofarr — Architecture Documentation
Comprehensive technical documentation covering the full architecture of the **sofarr** application: a personal media download dashboard that aggregates downloads from SABnzbd, Sonarr, Radarr, and qBittorrent, filtered by Emby user identity.
---
## Table of Contents
1. [System Overview](#1-system-overview)
2. [Technology Stack](#2-technology-stack)
3. [Directory Structure](#3-directory-structure)
4. [Component Architecture](#4-component-architecture)
5. [Data Flow](#5-data-flow)
6. [Authentication & Authorisation](#6-authentication--authorisation)
7. [Background Polling & Caching](#7-background-polling--caching)
8. [Download Matching Pipeline](#8-download-matching-pipeline)
9. [API Reference](#9-api-reference)
10. [Frontend Architecture](#10-frontend-architecture)
11. [Configuration](#11-configuration)
12. [Deployment](#12-deployment)
13. [UML Diagrams (PlantUML)](#13-uml-diagrams-plantuml)
---
## 1. System Overview
sofarr is a **single-page web application** with a Node.js/Express backend. It provides a personalised view of media downloads by:
1. **Authenticating** users against an Emby/Jellyfin media server.
2. **Aggregating** download data from multiple *arr service instances and download clients.
3. **Filtering** downloads by user — each user only sees media tagged with their username in Sonarr/Radarr.
4. **Presenting** a real-time dashboard with progress, speeds, cover art, and status.
Admin users can view all users' downloads, see server status, cache statistics, and poll timings.
### High-Level Architecture
```
┌─────────────────────────────────────────────────────┐
│ Browser (SPA) │
│ ┌──────────┐ ┌──────────┐ ┌───────────────────┐ │
│ │ Login │ │Dashboard │ │ Status Panel │ │
│ │ Form │ │ Cards │ │ (Admin only) │ │
│ └────┬─────┘ └────┬─────┘ └───────┬───────────┘ │
│ │ │ │ │
└───────┼──────────────┼────────────────┼──────────────┘
│ POST /login │ GET /user- │ GET /status
│ │ downloads │
▼ ▼ ▼
┌─────────────────────────────────────────────────────┐
│ Express Server (:3001) │
│ ┌────────┐ ┌──────────┐ ┌────────┐ ┌────────────┐ │
│ │ Auth │ │Dashboard │ │ Emby │ │ Static │ │
│ │ Routes │ │ Routes │ │ Routes │ │ Files │ │
│ └────┬───┘ └────┬─────┘ └────┬───┘ └────────────┘ │
│ │ │ │ │
│ ┌────┴──────────┴────────────┴──────────────────┐ │
│ │ Utilities Layer │ │
│ │ ┌────────┐ ┌────────┐ ┌──────┐ ┌──────────┐ │ │
│ │ │ Poller │ │ Cache │ │Config│ │qBittorrent│ │ │
│ │ └───┬────┘ └────────┘ └──────┘ └──────────┘ │ │
│ └──────┼────────────────────────────────────────┘ │
└─────────┼────────────────────────────────────────────┘
│ HTTP/API calls
┌──────────────────────────────────────────────────────┐
│ External Services │
│ ┌──────────┐ ┌────────┐ ┌────────┐ ┌────────────┐ │
│ │ SABnzbd │ │ Sonarr │ │ Radarr │ │qBittorrent │ │
│ │ (Usenet) │ │ (TV) │ │(Movie) │ │ (Torrent) │ │
│ └──────────┘ └────────┘ └────────┘ └────────────┘ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Emby / Jellyfin │ │
│ │ (Authentication + User DB) │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
```
---
## 2. Technology Stack
| Layer | Technology | Purpose |
|-------|-----------|---------|
| **Runtime** | Node.js 18+ | 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 |
| **Auth** | Emby API + httpOnly cookies | Session management |
| **Caching** | In-memory Map with TTL | Reduce external API load |
| **Scheduling** | `setInterval` | Background polling |
| **Containerisation** | Docker (Alpine) | Production deployment |
| **Logging** | Custom logger + `console.*` | File + stdout logging with levels |
---
## 3. Directory Structure
```
sofarr/
├── server/ # Backend application
│ ├── index.js # Entry point: Express setup, middleware, startup
│ ├── routes/
│ │ ├── auth.js # POST /login, GET /me, POST /logout
│ │ ├── dashboard.js # GET /user-downloads, /user-summary, /status
│ │ ├── 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
│ └── utils/
│ ├── cache.js # MemoryCache class (Map + TTL + stats)
│ ├── config.js # Multi-instance service configuration parser
│ ├── logger.js # File logger (server.log)
│ ├── poller.js # Background polling engine + timing
│ └── qbittorrent.js # qBittorrent client with auth + torrent mapping
├── 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
│ └── images/ # Logo / splash screen assets
├── Dockerfile # Production container image
├── docker-compose.yaml # Example compose deployment
├── package.json # Dependencies and scripts
├── .env.sample # Annotated environment variable template
└── README.md # User-facing documentation
```
---
## 4. Component Architecture
### 4.1 Server Entry Point (`server/index.js`)
Responsibilities:
- Load environment variables via `dotenv`
- Configure structured logging with level filtering (`LOG_LEVEL`)
- Redirect `console.*` to both stdout and `server.log`
- Mount Express middleware (CORS, cookie-parser, JSON, static files)
- Mount route modules under `/api/*`
- Start the background poller
### 4.2 Route Modules
| Module | Mount Point | Auth Required | Purpose |
|--------|------------|---------------|---------|
| `auth.js` | `/api/auth` | No | Login, session check, logout |
| `dashboard.js` | `/api/dashboard` | Yes (cookie) | Aggregated download data, status |
| `emby.js` | `/api/emby` | No | Proxy to Emby API |
| `sabnzbd.js` | `/api/sabnzbd` | No | Proxy to SABnzbd API |
| `sonarr.js` | `/api/sonarr` | No | Proxy to Sonarr API |
| `radarr.js` | `/api/radarr` | No | Proxy to Radarr API |
> **Note:** The proxy routes (`emby`, `sabnzbd`, `sonarr`, `radarr`) use legacy single-instance env vars and are largely unused by the dashboard — they exist for direct API access. The dashboard reads from the poller cache.
### 4.3 Utility Modules
**`config.js`** — Parses service instances from environment variables. Supports both JSON array format (`SONARR_INSTANCES=[{...}]`) and legacy single-instance format (`SONARR_URL` + `SONARR_API_KEY`). Each instance gets an `id` derived from `name` or index.
**`cache.js`** — Singleton `MemoryCache` backed by a `Map`. Each entry has an expiration timestamp. Provides `get`, `set`, `invalidate`, `clear`, and `getStats` (returns per-key size, item count, TTL remaining).
**`poller.js`** — Background polling engine. Fetches data from all configured service instances in parallel with per-task timing. Stores results in the cache with a configurable TTL. Can be disabled entirely (`POLL_INTERVAL=0`), in which case data is fetched on-demand per dashboard request.
**`qbittorrent.js`** — `QBittorrentClient` class with cookie-based authentication, automatic re-auth on 403, and persistent client instances. Includes torrent-to-download mapping (`mapTorrentToDownload`) and formatting utilities.
**`logger.js`** — Simple file appender writing timestamped messages to `server.log`.
---
## 5. Data Flow
### 5.1 Polling Cycle
Every `POLL_INTERVAL` ms (default 5000), the poller fetches from all services in parallel:
| Task | API Call | Params |
|------|----------|--------|
| SABnzbd Queue | `GET /api?mode=queue` | `output=json` |
| SABnzbd History | `GET /api?mode=history` | `limit=10` |
| Sonarr Tags | `GET /api/v3/tag` | — |
| Sonarr Queue | `GET /api/v3/queue` | `includeSeries=true` |
| Sonarr History | `GET /api/v3/history` | `pageSize=10` |
| Radarr Queue | `GET /api/v3/queue` | `includeMovie=true` |
| Radarr History | `GET /api/v3/history` | `pageSize=10` |
| Radarr Tags | `GET /api/v3/tag` | — |
| qBittorrent | `GET /api/v2/torrents/info` | — |
Results are stored in the cache under `poll:*` keys with a TTL of `POLL_INTERVAL × 3`.
### 5.2 Dashboard Request
When a user requests `/api/dashboard/user-downloads`:
1. Read all `poll:*` keys from cache
2. Build `seriesMap` and `moviesMap` from embedded objects in queue records
3. Build `sonarrTagMap` and `radarrTagMap` from tag data
4. For each SABnzbd queue slot → try to match against Sonarr/Radarr queue records by title
5. For each SABnzbd history slot → try to match against Sonarr/Radarr history records
6. For each qBittorrent torrent → try to match against Sonarr/Radarr queue, then history
7. For each match, resolve the series/movie, extract the user tag, check if it belongs to the requesting user
8. Return only the user's downloads (or all, if admin with `showAll=true`)
---
## 6. Authentication & Authorisation
### Flow
1. User submits credentials via the login form
2. Backend calls Emby `POST /Users/authenticatebyname`
3. On success, fetches full user profile to determine admin status
4. Sets an `httpOnly` cookie (`emby_user`) containing: `{ id, name, isAdmin, token }`
5. Cookie expires after 24 hours
6. All subsequent dashboard requests read this cookie for identity
### 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 |
### TTL Strategy
- **Polling enabled**: `POLL_INTERVAL × 3` — ensures data survives between polls even if one poll is slow
- **Polling disabled**: `30000` ms — stale after 30s, next request triggers a fresh fetch
### Active Client Tracking
Each dashboard request reports the client's refresh rate. The server tracks active clients in a `Map<username, { user, refreshRateMs, lastSeen }>`, pruning entries older than 30 seconds. This data powers the admin status panel's "effective refresh mode" display.
---
## 8. Download Matching Pipeline
The core logic in `dashboard.js` matches raw download client data to *arr service metadata to determine which user each download belongs to.
### Matching Strategy
For each download item (SABnzbd slot or qBittorrent torrent):
```
1. Try Sonarr QUEUE match (by title substring)
→ resolve series via seriesMap (embedded in queue record)
→ extract user tag → check tag matches requesting user
2. Try Radarr QUEUE match (by title substring)
→ resolve movie via moviesMap (embedded in queue record)
→ extract user tag → check tag matches requesting user
3. Try Sonarr HISTORY match (by title substring)
→ resolve series via seriesMap (from queue) using seriesId
→ extract user tag → check tag matches requesting user
4. Try Radarr HISTORY match (by title substring)
→ resolve movie via moviesMap (from queue) using movieId
→ extract user tag → check tag matches requesting user
```
### Title Matching
Matches are **bidirectional substring matches** (case-insensitive):
```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 |
| `userTag` | string | Matched user tag |
| `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.
**Request Body:**
```json
{ "username": "string", "password": "string" }
```
**Response (200):**
```json
{
"success": true,
"user": { "id": "string", "name": "string", "isAdmin": true }
}
```
**Response (401):**
```json
{ "success": false, "error": "Invalid username or password" }
```
**Side Effect:** Sets `emby_user` httpOnly cookie (24h TTL).
---
### `GET /api/auth/me`
Check current session.
**Response:**
```json
{
"authenticated": true,
"user": { "id": "string", "name": "string", "isAdmin": false }
}
```
---
### `POST /api/auth/logout`
Clear session cookie.
---
### `GET /api/dashboard/user-downloads`
Fetch downloads for the authenticated user.
**Query Parameters:**
| Param | Type | Description |
|-------|------|-------------|
| `showAll` | `"true"` | (Admin) Show all users' downloads |
| `refreshRate` | number (ms) | Client's current refresh rate for tracking |
**Response (200):**
```json
{
"user": "string",
"isAdmin": true,
"downloads": [ /* download objects */ ]
}
```
---
### `GET /api/dashboard/status`
Admin-only server status.
**Response (200):**
```json
{
"server": {
"uptimeSeconds": 3600,
"nodeVersion": "v18.19.0",
"memoryUsageMB": 45.2,
"heapUsedMB": 28.1,
"heapTotalMB": 35.0
},
"polling": {
"enabled": true,
"intervalMs": 5000,
"lastPoll": {
"totalMs": 1234,
"timestamp": "2026-05-16T00:00:00.000Z",
"tasks": [
{ "label": "SABnzbd Queue", "ms": 120 },
{ "label": "Sonarr Queue", "ms": 890 }
]
}
},
"cache": {
"entryCount": 9,
"totalSizeBytes": 51200,
"entries": [
{ "key": "poll:sab-queue", "sizeBytes": 1024, "itemCount": 3, "ttlRemainingMs": 12000, "expired": false }
]
},
"clients": [
{ "user": "Alice", "refreshRateMs": 5000, "lastSeen": 1715817600000 }
]
}
```
---
### `GET /api/dashboard/user-summary`
Admin-only per-user download counts (fetches live from APIs, not cached).
**Response (200):**
```json
[
{ "username": "Alice", "seriesCount": 12, "movieCount": 5 }
]
```
---
## 10. Frontend Architecture
The frontend is a **vanilla JavaScript SPA** with no build step. All logic resides in `app.js` (754 lines), styled by `style.css`, and structured by `index.html`.
### UI States
```
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Splash Screen│────▶│ Login Form │────▶│ Dashboard │
│ (on load) │ │ (if no │ │ (after auth) │
│ │ │ session) │ │ │
└──────────────┘ └──────────────┘ └──────────────┘
┌─────┴─────┐
│ Status │
│ Panel │
│ (admin) │
└───────────┘
```
### Key Frontend Functions
| Function | Purpose |
|----------|---------|
| `checkAuthentication()` | On load: check session → show dashboard or login |
| `handleLogin()` | Authenticate, fade login → splash → dashboard |
| `fetchUserDownloads()` | GET `/user-downloads`, update state, re-render |
| `renderDownloads()` | Diff-based card rendering (create/update/remove) |
| `createDownloadCard()` | Build DOM for a single download card |
| `updateDownloadCard()` | Update existing card in-place (progress, speed, etc.) |
| `toggleStatusPanel()` | Show/hide admin status panel |
| `renderStatusPanel()` | Build status HTML (server, polling, cache, clients) |
| `startAutoRefresh()` | Start periodic `fetchUserDownloads` |
| `initThemeSwitcher()` | Light / Dark / Mono theme support |
### Themes
Three CSS themes via `data-theme` attribute on `<html>`:
- **Light** — Purple gradient header, white cards
- **Dark** — Dark surfaces, muted accents
- **Mono** — Monochrome, minimal colour
Theme selection persists in `localStorage`.
### Auto-Refresh
The dashboard polls the server at the user-selected interval (1s, 5s, 10s, or Off). The status panel auto-refreshes in sync. Client refresh rate is sent to the server on each request for active-client tracking.
---
## 11. Configuration
### Environment Variables
| Variable | Required | Default | Description |
|----------|:--------:|---------|-------------|
| `PORT` | No | `3001` | Server listen port |
| `EMBY_URL` | Yes | — | Emby/Jellyfin server URL |
| `EMBY_API_KEY` | Yes | — | Emby API key |
| `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 |
| `POLL_INTERVAL` | No | `5000` | Poll interval in ms, or `off`/`0` to disable |
| `LOG_LEVEL` | No | `info` | `debug`, `info`, `warn`, `error`, `silent` |
\* Either `*_INSTANCES` (JSON array) or legacy `*_URL` + `*_API_KEY` format is required.
### 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
```dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY server/ ./server/
COPY public/ ./public/
EXPOSE 3001
ENV NODE_ENV=production
CMD ["node", "server/index.js"]
```
### Docker Compose
```yaml
version: "3"
services:
sofarr:
image: docker.i3omb.com/sofarr:latest
container_name: sofarr
restart: unless-stopped
ports:
- "3001:3001"
environment:
- 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
```
---
## 13. UML Diagrams (PlantUML)
All diagrams below are in PlantUML syntax. Render with any PlantUML tool or VS Code extension.
### 13.1 Component Diagram
See [`diagrams/component.puml`](diagrams/component.puml)
### 13.2 Sequence Diagrams
- **Authentication**: [`diagrams/seq-auth.puml`](diagrams/seq-auth.puml)
- **Dashboard Request**: [`diagrams/seq-dashboard.puml`](diagrams/seq-dashboard.puml)
- **Polling Cycle**: [`diagrams/seq-polling.puml`](diagrams/seq-polling.puml)
### 13.3 Class / Entity Diagrams
- **Server Classes**: [`diagrams/class-server.puml`](diagrams/class-server.puml)
- **Data Model**: [`diagrams/class-data.puml`](diagrams/class-data.puml)
### 13.4 State Diagrams
- **Frontend UI States**: [`diagrams/state-ui.puml`](diagrams/state-ui.puml)
- **Poller States**: [`diagrams/state-poller.puml`](diagrams/state-poller.puml)
### 13.5 Activity Diagram
- **Download Matching**: [`diagrams/activity-matching.puml`](diagrams/activity-matching.puml)