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:
609
docs/ARCHITECTURE.md
Normal file
609
docs/ARCHITECTURE.md
Normal 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)
|
||||||
128
docs/diagrams/activity-matching.puml
Normal file
128
docs/diagrams/activity-matching.puml
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
@startuml activity-matching
|
||||||
|
!theme plain
|
||||||
|
title sofarr — Download Matching Activity Diagram
|
||||||
|
|
||||||
|
start
|
||||||
|
|
||||||
|
:Read cached data from MemoryCache;
|
||||||
|
note right
|
||||||
|
poll:sab-queue, poll:sab-history,
|
||||||
|
poll:sonarr-queue, poll:sonarr-history,
|
||||||
|
poll:radarr-queue, poll:radarr-history,
|
||||||
|
poll:sonarr-tags, poll:radarr-tags,
|
||||||
|
poll:qbittorrent
|
||||||
|
end note
|
||||||
|
|
||||||
|
:Build **seriesMap** from Sonarr queue records
|
||||||
|
(seriesId → embedded series object);
|
||||||
|
|
||||||
|
:Build **moviesMap** from Radarr queue records
|
||||||
|
(movieId → embedded movie object);
|
||||||
|
|
||||||
|
:Build **sonarrTagMap** (tagId → label)
|
||||||
|
Build **radarrTagMap** (tagId → label);
|
||||||
|
|
||||||
|
:Initialise **userDownloads** = [];
|
||||||
|
|
||||||
|
partition "Process SABnzbd Queue Slots" {
|
||||||
|
while (More queue slots?) is (yes)
|
||||||
|
:Get slot filename (nzbName);
|
||||||
|
:nzbNameLower = nzbName.toLowerCase();
|
||||||
|
|
||||||
|
if (Title matches Sonarr **queue** record?) then (yes)
|
||||||
|
:series = seriesMap.get(match.seriesId)\n|| match.series;
|
||||||
|
if (series exists?) then (yes)
|
||||||
|
:userTag = extractUserTag(series.tags, sonarrTagMap);
|
||||||
|
if (showAll OR tagMatchesUser?) then (yes)
|
||||||
|
:Build download object (type=series)
|
||||||
|
Add coverArt, status, progress, speed, eta
|
||||||
|
Add importIssues if any
|
||||||
|
Add admin fields (paths, arrLink);
|
||||||
|
:Push to **userDownloads**;
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
|
||||||
|
if (Title matches Radarr **queue** record?) then (yes)
|
||||||
|
:movie = moviesMap.get(match.movieId)\n|| match.movie;
|
||||||
|
if (movie exists?) then (yes)
|
||||||
|
:userTag = extractUserTag(movie.tags, radarrTagMap);
|
||||||
|
if (showAll OR tagMatchesUser?) then (yes)
|
||||||
|
:Build download object (type=movie)
|
||||||
|
Add coverArt, status, progress, speed, eta
|
||||||
|
Add importIssues if any
|
||||||
|
Add admin fields (paths, arrLink);
|
||||||
|
:Push to **userDownloads**;
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
endwhile (no)
|
||||||
|
}
|
||||||
|
|
||||||
|
partition "Process SABnzbd History Slots" {
|
||||||
|
while (More history slots?) is (yes)
|
||||||
|
:Get slot name (nzbName);
|
||||||
|
:nzbNameLower = nzbName.toLowerCase();
|
||||||
|
|
||||||
|
if (Title matches Sonarr **history** record?) then (yes)
|
||||||
|
:series = seriesMap.get(match.seriesId)\n|| match.series;
|
||||||
|
if (series found?) then (yes)
|
||||||
|
:Check user tag, build download\n(type=series, with completedAt);
|
||||||
|
:Push to **userDownloads** if tag matches;
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
|
||||||
|
if (Title matches Radarr **history** record?) then (yes)
|
||||||
|
:movie = moviesMap.get(match.movieId)\n|| match.movie;
|
||||||
|
if (movie found?) then (yes)
|
||||||
|
:Check user tag, build download\n(type=movie, with completedAt);
|
||||||
|
:Push to **userDownloads** if tag matches;
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
endwhile (no)
|
||||||
|
}
|
||||||
|
|
||||||
|
partition "Process qBittorrent Torrents" {
|
||||||
|
while (More torrents?) is (yes)
|
||||||
|
:Get torrent name;
|
||||||
|
:torrentNameLower = name.toLowerCase();
|
||||||
|
|
||||||
|
if (Matches Sonarr **queue**?) then (yes)
|
||||||
|
:Resolve series → check tag;
|
||||||
|
:mapTorrentToDownload() + enrich;
|
||||||
|
:Push if matches → **continue**;
|
||||||
|
elseif (Matches Radarr **queue**?) then (yes)
|
||||||
|
:Resolve movie → check tag;
|
||||||
|
:mapTorrentToDownload() + enrich;
|
||||||
|
:Push if matches → **continue**;
|
||||||
|
elseif (Matches Sonarr **history**?) then (yes)
|
||||||
|
:Resolve series via seriesMap;
|
||||||
|
:mapTorrentToDownload() + enrich;
|
||||||
|
:Push if matches → **continue**;
|
||||||
|
elseif (Matches Radarr **history**?) then (yes)
|
||||||
|
:Resolve movie via moviesMap;
|
||||||
|
:mapTorrentToDownload() + enrich;
|
||||||
|
:Push if matches → **continue**;
|
||||||
|
else (no match)
|
||||||
|
:Skip torrent (unmatched);
|
||||||
|
endif
|
||||||
|
endwhile (no)
|
||||||
|
}
|
||||||
|
|
||||||
|
:Return JSON response
|
||||||
|
{ user, isAdmin, downloads: userDownloads };
|
||||||
|
|
||||||
|
stop
|
||||||
|
|
||||||
|
legend right
|
||||||
|
**Title Matching Logic**
|
||||||
|
(bidirectional substring, case-insensitive):
|
||||||
|
""rTitle.includes(dlTitle) || dlTitle.includes(rTitle)""
|
||||||
|
|
||||||
|
**Tag Matching Logic**:
|
||||||
|
1. Exact: tag.toLowerCase() === username
|
||||||
|
2. Sanitised: sanitizeTagLabel(tag) === sanitizeTagLabel(username)
|
||||||
|
(handles Ombi-mangled email-style usernames)
|
||||||
|
end legend
|
||||||
|
|
||||||
|
@enduml
|
||||||
221
docs/diagrams/class-data.puml
Normal file
221
docs/diagrams/class-data.puml
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
@startuml class-data
|
||||||
|
!theme plain
|
||||||
|
title sofarr — Data Model Diagram
|
||||||
|
|
||||||
|
skinparam classAttributeIconSize 0
|
||||||
|
|
||||||
|
package "External API Responses" {
|
||||||
|
class "SABnzbd Queue Slot" as sabq {
|
||||||
|
+ filename : string
|
||||||
|
+ nzbname : string
|
||||||
|
+ percentage : string
|
||||||
|
+ mb : string
|
||||||
|
+ mbmissing : string
|
||||||
|
+ size : string
|
||||||
|
+ timeleft : string
|
||||||
|
+ status : string
|
||||||
|
+ storage : string
|
||||||
|
}
|
||||||
|
|
||||||
|
class "SABnzbd History Slot" as sabh {
|
||||||
|
+ name : string
|
||||||
|
+ nzb_name : string
|
||||||
|
+ nzbname : string
|
||||||
|
+ status : string
|
||||||
|
+ size : string
|
||||||
|
+ completed_time : string
|
||||||
|
+ storage : string
|
||||||
|
}
|
||||||
|
|
||||||
|
class "Sonarr Queue Record" as sqr {
|
||||||
|
+ id : number
|
||||||
|
+ seriesId : number
|
||||||
|
+ series : SonarrSeries
|
||||||
|
+ title : string
|
||||||
|
+ sourceTitle : string
|
||||||
|
+ trackedDownloadStatus : string
|
||||||
|
+ trackedDownloadState : string
|
||||||
|
+ statusMessages : StatusMessage[]
|
||||||
|
+ errorMessage : string
|
||||||
|
}
|
||||||
|
|
||||||
|
class "Sonarr History Record" as shr {
|
||||||
|
+ id : number
|
||||||
|
+ seriesId : number
|
||||||
|
+ title : string
|
||||||
|
+ sourceTitle : string
|
||||||
|
+ eventType : string
|
||||||
|
}
|
||||||
|
|
||||||
|
class "SonarrSeries" as ss {
|
||||||
|
+ id : number
|
||||||
|
+ title : string
|
||||||
|
+ titleSlug : string
|
||||||
|
+ path : string
|
||||||
|
+ tags : number[]
|
||||||
|
+ images : Image[]
|
||||||
|
+ _instanceUrl : string
|
||||||
|
}
|
||||||
|
|
||||||
|
class "Radarr Queue Record" as rqr {
|
||||||
|
+ id : number
|
||||||
|
+ movieId : number
|
||||||
|
+ movie : RadarrMovie
|
||||||
|
+ title : string
|
||||||
|
+ sourceTitle : string
|
||||||
|
+ trackedDownloadStatus : string
|
||||||
|
+ trackedDownloadState : string
|
||||||
|
+ statusMessages : StatusMessage[]
|
||||||
|
+ errorMessage : string
|
||||||
|
}
|
||||||
|
|
||||||
|
class "Radarr History Record" as rhr {
|
||||||
|
+ id : number
|
||||||
|
+ movieId : number
|
||||||
|
+ title : string
|
||||||
|
+ sourceTitle : string
|
||||||
|
+ eventType : string
|
||||||
|
}
|
||||||
|
|
||||||
|
class "RadarrMovie" as rm {
|
||||||
|
+ id : number
|
||||||
|
+ title : string
|
||||||
|
+ titleSlug : string
|
||||||
|
+ path : string
|
||||||
|
+ tags : number[]
|
||||||
|
+ images : Image[]
|
||||||
|
+ _instanceUrl : string
|
||||||
|
}
|
||||||
|
|
||||||
|
class "Tag" as tag {
|
||||||
|
+ id : number
|
||||||
|
+ label : string
|
||||||
|
}
|
||||||
|
|
||||||
|
class "Image" as img {
|
||||||
|
+ coverType : string
|
||||||
|
+ remoteUrl : string
|
||||||
|
+ url : string
|
||||||
|
}
|
||||||
|
|
||||||
|
class "StatusMessage" as sm {
|
||||||
|
+ title : string
|
||||||
|
+ messages : string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
class "qBittorrent Torrent" as qbt {
|
||||||
|
+ name : string
|
||||||
|
+ hash : string
|
||||||
|
+ size : number
|
||||||
|
+ completed : number
|
||||||
|
+ progress : number (0-1)
|
||||||
|
+ state : string
|
||||||
|
+ dlspeed : number
|
||||||
|
+ eta : number
|
||||||
|
+ num_seeds : number
|
||||||
|
+ num_leechs : number
|
||||||
|
+ availability : number
|
||||||
|
+ category : string
|
||||||
|
+ tags : string
|
||||||
|
+ save_path : string
|
||||||
|
+ content_path : string
|
||||||
|
+ instanceId : string
|
||||||
|
+ instanceName : string
|
||||||
|
}
|
||||||
|
|
||||||
|
class "Emby User" as eu {
|
||||||
|
+ Id : string
|
||||||
|
+ Name : string
|
||||||
|
+ Policy : { IsAdministrator: boolean }
|
||||||
|
}
|
||||||
|
|
||||||
|
sqr *-- ss : embedded\n(includeSeries)
|
||||||
|
rqr *-- rm : embedded\n(includeMovie)
|
||||||
|
sqr *-- sm
|
||||||
|
rqr *-- sm
|
||||||
|
ss *-- img
|
||||||
|
rm *-- img
|
||||||
|
}
|
||||||
|
|
||||||
|
package "sofarr Internal Models" {
|
||||||
|
class "Download Object" as dl {
|
||||||
|
+ type : 'series' | 'movie' | 'torrent'
|
||||||
|
+ title : string
|
||||||
|
+ coverArt : string | null
|
||||||
|
+ status : string
|
||||||
|
+ progress : string
|
||||||
|
+ mb : string
|
||||||
|
+ mbmissing : string
|
||||||
|
+ size : string
|
||||||
|
+ speed : string
|
||||||
|
+ eta : string
|
||||||
|
+ seriesName : string | null
|
||||||
|
+ movieName : string | null
|
||||||
|
+ episodeInfo : object | null
|
||||||
|
+ movieInfo : object | null
|
||||||
|
+ userTag : string
|
||||||
|
+ importIssues : string[] | null
|
||||||
|
+ downloadPath : string | null
|
||||||
|
+ targetPath : string | null
|
||||||
|
+ arrLink : string | null
|
||||||
|
+ qbittorrent : boolean
|
||||||
|
+ seeds : number
|
||||||
|
+ peers : number
|
||||||
|
+ availability : string
|
||||||
|
+ rawSize : number
|
||||||
|
+ rawSpeed : number
|
||||||
|
+ rawEta : number
|
||||||
|
+ hash : string
|
||||||
|
+ category : string
|
||||||
|
+ completedAt : string
|
||||||
|
}
|
||||||
|
|
||||||
|
class "API Response\n/user-downloads" as apir {
|
||||||
|
+ user : string
|
||||||
|
+ isAdmin : boolean
|
||||||
|
+ downloads : Download[]
|
||||||
|
}
|
||||||
|
|
||||||
|
class "Status Response\n/status" as statr {
|
||||||
|
+ server : ServerInfo
|
||||||
|
+ polling : PollingInfo
|
||||||
|
+ cache : CacheStats
|
||||||
|
+ clients : ClientInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
class "ServerInfo" as si {
|
||||||
|
+ uptimeSeconds : number
|
||||||
|
+ nodeVersion : string
|
||||||
|
+ memoryUsageMB : number
|
||||||
|
+ heapUsedMB : number
|
||||||
|
+ heapTotalMB : number
|
||||||
|
}
|
||||||
|
|
||||||
|
class "PollingInfo" as pi {
|
||||||
|
+ enabled : boolean
|
||||||
|
+ intervalMs : number
|
||||||
|
+ lastPoll : PollTimings
|
||||||
|
}
|
||||||
|
|
||||||
|
class "Session Cookie\nemby_user" as cookie {
|
||||||
|
+ id : string
|
||||||
|
+ name : string
|
||||||
|
+ isAdmin : boolean
|
||||||
|
+ token : string
|
||||||
|
}
|
||||||
|
|
||||||
|
apir *-- dl
|
||||||
|
statr *-- si
|
||||||
|
statr *-- pi
|
||||||
|
}
|
||||||
|
|
||||||
|
' Data flow connections
|
||||||
|
sabq ..> dl : matched &\ntransformed
|
||||||
|
sabh ..> dl : matched &\ntransformed
|
||||||
|
qbt ..> dl : mapTorrentToDownload()
|
||||||
|
ss ..> dl : coverArt, seriesName,\npath, tags
|
||||||
|
rm ..> dl : coverArt, movieName,\npath, tags
|
||||||
|
tag ..> dl : userTag resolution
|
||||||
|
eu ..> cookie : login creates
|
||||||
|
|
||||||
|
@enduml
|
||||||
197
docs/diagrams/class-server.puml
Normal file
197
docs/diagrams/class-server.puml
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
@startuml class-server
|
||||||
|
!theme plain
|
||||||
|
title sofarr — Server Class / Module Diagram
|
||||||
|
|
||||||
|
package "server/index.js" as entry {
|
||||||
|
class "EntryPoint" as ep <<module>> {
|
||||||
|
- LOG_LEVELS : Object
|
||||||
|
- currentLevel : number
|
||||||
|
- logFile : WriteStream
|
||||||
|
+ shouldLog(level) : boolean
|
||||||
|
--
|
||||||
|
Configures Express app,
|
||||||
|
mounts routes, starts poller
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
package "server/routes" {
|
||||||
|
class "auth.js" as auth <<router>> {
|
||||||
|
+ POST /login
|
||||||
|
+ GET /me
|
||||||
|
+ POST /logout
|
||||||
|
--
|
||||||
|
Authenticates via Emby API
|
||||||
|
Sets/reads httpOnly cookie
|
||||||
|
}
|
||||||
|
|
||||||
|
class "dashboard.js" as dashboard <<router>> {
|
||||||
|
- activeClients : Map<string, ClientInfo>
|
||||||
|
- CLIENT_STALE_MS : 30000
|
||||||
|
--
|
||||||
|
+ GET /user-downloads
|
||||||
|
+ GET /user-summary
|
||||||
|
+ GET /status
|
||||||
|
--
|
||||||
|
- getCoverArt(item) : string|null
|
||||||
|
- extractUserTag(tags, tagMap) : string|null
|
||||||
|
- sanitizeTagLabel(input) : string
|
||||||
|
- tagMatchesUser(tag, username) : boolean
|
||||||
|
- getImportIssues(record) : string[]|null
|
||||||
|
- getSonarrLink(series) : string|null
|
||||||
|
- getRadarrLink(movie) : string|null
|
||||||
|
- getActiveClients() : ClientInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
class "emby.js" as emby_r <<router>> {
|
||||||
|
+ GET /sessions
|
||||||
|
+ GET /users/:id
|
||||||
|
+ GET /users
|
||||||
|
+ GET /session/:sessionId/user
|
||||||
|
}
|
||||||
|
|
||||||
|
class "sabnzbd.js" as sab_r <<router>> {
|
||||||
|
+ GET /queue
|
||||||
|
+ GET /history
|
||||||
|
}
|
||||||
|
|
||||||
|
class "sonarr.js" as sonarr_r <<router>> {
|
||||||
|
+ GET /queue
|
||||||
|
+ GET /history
|
||||||
|
+ GET /series/:id
|
||||||
|
+ GET /series
|
||||||
|
}
|
||||||
|
|
||||||
|
class "radarr.js" as radarr_r <<router>> {
|
||||||
|
+ GET /queue
|
||||||
|
+ GET /history
|
||||||
|
+ GET /movies/:id
|
||||||
|
+ GET /movies
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
package "server/utils" {
|
||||||
|
class "MemoryCache" as cache {
|
||||||
|
- store : Map<string, CacheEntry>
|
||||||
|
+ get(key) : any|null
|
||||||
|
+ set(key, value, ttlMs) : void
|
||||||
|
+ invalidate(key) : void
|
||||||
|
+ clear() : void
|
||||||
|
+ getStats() : CacheStats
|
||||||
|
}
|
||||||
|
|
||||||
|
class "CacheEntry" as ce <<value>> {
|
||||||
|
+ value : any
|
||||||
|
+ expiresAt : number
|
||||||
|
}
|
||||||
|
|
||||||
|
class "CacheStats" as cs <<value>> {
|
||||||
|
+ entryCount : number
|
||||||
|
+ totalSizeBytes : number
|
||||||
|
+ entries : CacheEntryStats[]
|
||||||
|
}
|
||||||
|
|
||||||
|
class "Poller" as poller <<module>> {
|
||||||
|
- POLL_INTERVAL : number
|
||||||
|
- POLLING_ENABLED : boolean
|
||||||
|
- polling : boolean
|
||||||
|
- lastPollTimings : PollTimings|null
|
||||||
|
- intervalHandle : number|null
|
||||||
|
--
|
||||||
|
+ startPoller() : void
|
||||||
|
+ stopPoller() : void
|
||||||
|
+ pollAllServices() : Promise<void>
|
||||||
|
+ getLastPollTimings() : PollTimings|null
|
||||||
|
--
|
||||||
|
- timed(label, fn) : TimedResult
|
||||||
|
}
|
||||||
|
|
||||||
|
class "PollTimings" as pt <<value>> {
|
||||||
|
+ totalMs : number
|
||||||
|
+ timestamp : string (ISO)
|
||||||
|
+ tasks : { label, ms }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
class "Config" as config <<module>> {
|
||||||
|
+ getSABnzbdInstances() : Instance[]
|
||||||
|
+ getSonarrInstances() : Instance[]
|
||||||
|
+ getRadarrInstances() : Instance[]
|
||||||
|
+ getQbittorrentInstances() : Instance[]
|
||||||
|
--
|
||||||
|
- parseInstances(envVar, ...) : Instance[]
|
||||||
|
}
|
||||||
|
|
||||||
|
class "Instance" as inst <<value>> {
|
||||||
|
+ id : string
|
||||||
|
+ name : string
|
||||||
|
+ url : string
|
||||||
|
+ apiKey : string
|
||||||
|
+ username? : string
|
||||||
|
+ password? : string
|
||||||
|
}
|
||||||
|
|
||||||
|
class "QBittorrentClient" as qbt {
|
||||||
|
- id : string
|
||||||
|
- name : string
|
||||||
|
- url : string
|
||||||
|
- username : string
|
||||||
|
- password : string
|
||||||
|
- authCookie : string|null
|
||||||
|
--
|
||||||
|
+ login() : Promise<boolean>
|
||||||
|
+ makeRequest(endpoint, config) : Promise<Response>
|
||||||
|
+ getTorrents() : Promise<Torrent[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
class "qbittorrent.js" as qbt_mod <<module>> {
|
||||||
|
- persistedClients : QBittorrentClient[]|null
|
||||||
|
--
|
||||||
|
+ getTorrents() : Promise<Torrent[]>
|
||||||
|
+ getClients() : QBittorrentClient[]
|
||||||
|
+ mapTorrentToDownload(torrent) : Download
|
||||||
|
+ formatBytes(bytes) : string
|
||||||
|
+ formatSpeed(bps) : string
|
||||||
|
+ formatEta(seconds) : string
|
||||||
|
}
|
||||||
|
|
||||||
|
class "Logger" as logger <<module>> {
|
||||||
|
- logFile : WriteStream
|
||||||
|
+ logToFile(message) : void
|
||||||
|
}
|
||||||
|
|
||||||
|
class "ClientInfo" as ci <<value>> {
|
||||||
|
+ user : string
|
||||||
|
+ refreshRateMs : number
|
||||||
|
+ lastSeen : number (timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
' Relationships
|
||||||
|
ep --> auth
|
||||||
|
ep --> dashboard
|
||||||
|
ep --> emby_r
|
||||||
|
ep --> sab_r
|
||||||
|
ep --> sonarr_r
|
||||||
|
ep --> radarr_r
|
||||||
|
ep --> poller : startPoller()
|
||||||
|
|
||||||
|
dashboard --> cache : read/write
|
||||||
|
dashboard --> poller : pollAllServices()
|
||||||
|
dashboard --> qbt_mod : mapTorrentToDownload()
|
||||||
|
dashboard --> config
|
||||||
|
|
||||||
|
poller --> cache : set poll:* keys
|
||||||
|
poller --> config : get instances
|
||||||
|
poller --> qbt_mod : getTorrents()
|
||||||
|
|
||||||
|
qbt_mod --> config : getQbittorrentInstances()
|
||||||
|
qbt_mod *-- qbt : creates
|
||||||
|
qbt --> logger
|
||||||
|
|
||||||
|
cache *-- ce : stores
|
||||||
|
cache ..> cs : returns from getStats()
|
||||||
|
poller ..> pt : stores/returns
|
||||||
|
dashboard *-- ci : stores in activeClients
|
||||||
|
|
||||||
|
config ..> inst : returns
|
||||||
|
|
||||||
|
@enduml
|
||||||
94
docs/diagrams/component.puml
Normal file
94
docs/diagrams/component.puml
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
@startuml component
|
||||||
|
!theme plain
|
||||||
|
title sofarr — Component Diagram
|
||||||
|
|
||||||
|
skinparam componentStyle rectangle
|
||||||
|
skinparam packageStyle frame
|
||||||
|
|
||||||
|
package "Browser" as browser {
|
||||||
|
[index.html] as html
|
||||||
|
[app.js] as appjs
|
||||||
|
[style.css] as css
|
||||||
|
html ..> appjs : loads
|
||||||
|
html ..> css : loads
|
||||||
|
}
|
||||||
|
|
||||||
|
package "Express Server" as server {
|
||||||
|
|
||||||
|
package "Middleware" {
|
||||||
|
[CORS] as cors
|
||||||
|
[cookie-parser] as cp
|
||||||
|
[express.json] as ej
|
||||||
|
[express.static] as es
|
||||||
|
}
|
||||||
|
|
||||||
|
package "Routes" as routes {
|
||||||
|
[auth.js\n/api/auth] as auth
|
||||||
|
[dashboard.js\n/api/dashboard] as dashboard
|
||||||
|
[emby.js\n/api/emby] as emby_route
|
||||||
|
[sabnzbd.js\n/api/sabnzbd] as sab_route
|
||||||
|
[sonarr.js\n/api/sonarr] as sonarr_route
|
||||||
|
[radarr.js\n/api/radarr] as radarr_route
|
||||||
|
}
|
||||||
|
|
||||||
|
package "Utilities" as utils {
|
||||||
|
[poller.js] as poller
|
||||||
|
[cache.js\nMemoryCache] as cache
|
||||||
|
[config.js] as config
|
||||||
|
[qbittorrent.js\nQBittorrentClient] as qbt
|
||||||
|
[logger.js] as logger
|
||||||
|
}
|
||||||
|
|
||||||
|
[index.js\nEntry Point] as entry
|
||||||
|
|
||||||
|
entry --> cors
|
||||||
|
entry --> cp
|
||||||
|
entry --> ej
|
||||||
|
entry --> es
|
||||||
|
entry --> auth
|
||||||
|
entry --> dashboard
|
||||||
|
entry --> emby_route
|
||||||
|
entry --> sab_route
|
||||||
|
entry --> sonarr_route
|
||||||
|
entry --> radarr_route
|
||||||
|
entry --> poller : startPoller()
|
||||||
|
|
||||||
|
dashboard --> cache : read poll:* keys
|
||||||
|
dashboard --> poller : pollAllServices()\n(on-demand mode)
|
||||||
|
dashboard --> config : getSonarrInstances()\ngetRadarrInstances()
|
||||||
|
dashboard --> qbt : mapTorrentToDownload()
|
||||||
|
|
||||||
|
poller --> cache : set poll:* keys
|
||||||
|
poller --> config : get all instances
|
||||||
|
poller --> qbt : getTorrents()
|
||||||
|
poller --> logger
|
||||||
|
|
||||||
|
qbt --> config : getQbittorrentInstances()
|
||||||
|
qbt --> logger
|
||||||
|
}
|
||||||
|
|
||||||
|
cloud "External Services" as external {
|
||||||
|
[Emby / Jellyfin] as emby
|
||||||
|
[SABnzbd] as sab
|
||||||
|
[Sonarr] as sonarr
|
||||||
|
[Radarr] as radarr
|
||||||
|
[qBittorrent] as qbit
|
||||||
|
}
|
||||||
|
|
||||||
|
auth --> emby : authenticate\nuser profile
|
||||||
|
dashboard ..> emby : /user-summary\n(live fetch)
|
||||||
|
emby_route --> emby
|
||||||
|
sab_route --> sab
|
||||||
|
sonarr_route --> sonarr
|
||||||
|
radarr_route --> radarr
|
||||||
|
|
||||||
|
poller --> sab : queue + history
|
||||||
|
poller --> sonarr : tags + queue + history
|
||||||
|
poller --> radarr : tags + queue + history
|
||||||
|
qbt --> qbit : login + torrents/info
|
||||||
|
|
||||||
|
appjs --> auth : POST /login\nGET /me
|
||||||
|
appjs --> dashboard : GET /user-downloads\nGET /status
|
||||||
|
es --> html : serve static
|
||||||
|
|
||||||
|
@enduml
|
||||||
67
docs/diagrams/seq-auth.puml
Normal file
67
docs/diagrams/seq-auth.puml
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
@startuml seq-auth
|
||||||
|
!theme plain
|
||||||
|
title sofarr — Authentication Sequence
|
||||||
|
|
||||||
|
actor User as user
|
||||||
|
participant "Browser\n(app.js)" as browser
|
||||||
|
participant "Express\n/api/auth" as auth
|
||||||
|
participant "Emby\nServer" as emby
|
||||||
|
|
||||||
|
== Page Load ==
|
||||||
|
user -> browser : Navigate to sofarr
|
||||||
|
activate browser
|
||||||
|
browser -> auth : GET /api/auth/me
|
||||||
|
activate auth
|
||||||
|
auth -> auth : Read emby_user cookie
|
||||||
|
alt Cookie exists and valid
|
||||||
|
auth --> browser : { authenticated: true, user: { name, isAdmin } }
|
||||||
|
browser -> browser : showDashboard()
|
||||||
|
browser -> browser : fetchUserDownloads(true)
|
||||||
|
browser -> browser : startAutoRefresh()
|
||||||
|
browser -> browser : dismissSplash()
|
||||||
|
else No cookie
|
||||||
|
auth --> browser : { authenticated: false }
|
||||||
|
browser -> browser : dismissSplash()
|
||||||
|
browser -> browser : showLogin()
|
||||||
|
end
|
||||||
|
deactivate auth
|
||||||
|
|
||||||
|
== Login ==
|
||||||
|
user -> browser : Enter username + password
|
||||||
|
browser -> auth : POST /api/auth/login\n{ username, password }
|
||||||
|
activate auth
|
||||||
|
auth -> emby : POST /Users/authenticatebyname\n{ Username, Pw }
|
||||||
|
activate emby
|
||||||
|
alt Valid credentials
|
||||||
|
emby --> auth : { User: { Id, ... }, AccessToken }
|
||||||
|
auth -> emby : GET /Users/{userId}
|
||||||
|
emby --> auth : { Name, Policy: { IsAdministrator } }
|
||||||
|
deactivate emby
|
||||||
|
auth -> auth : Set httpOnly cookie\nemby_user = { id, name, isAdmin, token }\n(24h TTL)
|
||||||
|
auth --> browser : { success: true, user: { name, isAdmin } }
|
||||||
|
browser -> browser : fadeOutLogin()
|
||||||
|
browser -> browser : showSplash()
|
||||||
|
browser -> browser : showDashboard()
|
||||||
|
browser -> browser : fetchUserDownloads(true)
|
||||||
|
browser -> browser : startAutoRefresh()
|
||||||
|
browser -> browser : dismissSplash()
|
||||||
|
else Invalid credentials
|
||||||
|
emby --> auth : 401 Error
|
||||||
|
deactivate emby
|
||||||
|
auth --> browser : { success: false, error: "Invalid..." }
|
||||||
|
browser -> browser : showLoginError()
|
||||||
|
end
|
||||||
|
deactivate auth
|
||||||
|
|
||||||
|
== Logout ==
|
||||||
|
user -> browser : Click Logout
|
||||||
|
browser -> browser : stopAutoRefresh()
|
||||||
|
browser -> auth : POST /api/auth/logout
|
||||||
|
activate auth
|
||||||
|
auth -> auth : Clear emby_user cookie
|
||||||
|
auth --> browser : { success: true }
|
||||||
|
deactivate auth
|
||||||
|
browser -> browser : showLogin()
|
||||||
|
|
||||||
|
deactivate browser
|
||||||
|
@enduml
|
||||||
85
docs/diagrams/seq-dashboard.puml
Normal file
85
docs/diagrams/seq-dashboard.puml
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
@startuml seq-dashboard
|
||||||
|
!theme plain
|
||||||
|
title sofarr — Dashboard Request Sequence
|
||||||
|
|
||||||
|
actor User as user
|
||||||
|
participant "Browser\n(app.js)" as browser
|
||||||
|
participant "Express\n/api/dashboard" as dashboard
|
||||||
|
participant "MemoryCache" as cache
|
||||||
|
participant "Poller" as poller
|
||||||
|
participant "External\nServices" as ext
|
||||||
|
|
||||||
|
== Periodic Refresh (or Initial Load) ==
|
||||||
|
user -> browser : (auto-refresh fires)
|
||||||
|
activate browser
|
||||||
|
browser -> dashboard : GET /api/dashboard/user-downloads\n?refreshRate=5000&showAll=false
|
||||||
|
activate dashboard
|
||||||
|
|
||||||
|
dashboard -> dashboard : Parse emby_user cookie\nExtract username, isAdmin
|
||||||
|
dashboard -> dashboard : Track client refresh rate\nin activeClients Map
|
||||||
|
|
||||||
|
alt Polling disabled AND cache empty
|
||||||
|
dashboard -> poller : pollAllServices()
|
||||||
|
activate poller
|
||||||
|
poller -> ext : Parallel API calls\n(SAB, Sonarr, Radarr, qBit)
|
||||||
|
ext --> poller : Raw data
|
||||||
|
poller -> cache : set poll:* keys\n(TTL = 30s)
|
||||||
|
deactivate poller
|
||||||
|
end
|
||||||
|
|
||||||
|
dashboard -> cache : get('poll:sab-queue')
|
||||||
|
cache --> dashboard : { slots, status, speed }
|
||||||
|
dashboard -> cache : get('poll:sab-history')
|
||||||
|
cache --> dashboard : { slots }
|
||||||
|
dashboard -> cache : get('poll:sonarr-tags')
|
||||||
|
cache --> dashboard : [{ instance, data }]
|
||||||
|
dashboard -> cache : get('poll:sonarr-queue')
|
||||||
|
cache --> dashboard : { records } (with embedded series)
|
||||||
|
dashboard -> cache : get('poll:sonarr-history')
|
||||||
|
cache --> dashboard : { records }
|
||||||
|
dashboard -> cache : get('poll:radarr-queue')
|
||||||
|
cache --> dashboard : { records } (with embedded movie)
|
||||||
|
dashboard -> cache : get('poll:radarr-history')
|
||||||
|
cache --> dashboard : { records }
|
||||||
|
dashboard -> cache : get('poll:radarr-tags')
|
||||||
|
cache --> dashboard : [{id, label}]
|
||||||
|
dashboard -> cache : get('poll:qbittorrent')
|
||||||
|
cache --> dashboard : [torrent, ...]
|
||||||
|
|
||||||
|
dashboard -> dashboard : Build seriesMap from\nSonarr queue records
|
||||||
|
dashboard -> dashboard : Build moviesMap from\nRadarr queue records
|
||||||
|
dashboard -> dashboard : Build tag maps\n(id → label)
|
||||||
|
|
||||||
|
group SABnzbd Queue Matching
|
||||||
|
loop each queue slot
|
||||||
|
dashboard -> dashboard : Match title vs Sonarr queue
|
||||||
|
dashboard -> dashboard : Match title vs Radarr queue
|
||||||
|
dashboard -> dashboard : Resolve series/movie\n→ extract user tag\n→ filter by username
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
group SABnzbd History Matching
|
||||||
|
loop each history slot
|
||||||
|
dashboard -> dashboard : Match title vs Sonarr history
|
||||||
|
dashboard -> dashboard : Match title vs Radarr history
|
||||||
|
dashboard -> dashboard : Resolve via seriesMap/moviesMap\n→ extract user tag\n→ filter
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
group qBittorrent Matching
|
||||||
|
loop each torrent
|
||||||
|
dashboard -> dashboard : 1. Match vs Sonarr queue
|
||||||
|
dashboard -> dashboard : 2. Match vs Radarr queue
|
||||||
|
dashboard -> dashboard : 3. Match vs Sonarr history
|
||||||
|
dashboard -> dashboard : 4. Match vs Radarr history
|
||||||
|
dashboard -> dashboard : mapTorrentToDownload()\n→ enrich with series/movie info
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
dashboard --> browser : { user, isAdmin,\ndownloads: [...] }
|
||||||
|
deactivate dashboard
|
||||||
|
|
||||||
|
browser -> browser : renderDownloads()\n(diff-based update)
|
||||||
|
deactivate browser
|
||||||
|
|
||||||
|
@enduml
|
||||||
89
docs/diagrams/seq-polling.puml
Normal file
89
docs/diagrams/seq-polling.puml
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
@startuml seq-polling
|
||||||
|
!theme plain
|
||||||
|
title sofarr — Background Polling Cycle
|
||||||
|
|
||||||
|
participant "index.js\n(startup)" as entry
|
||||||
|
participant "Poller" as poller
|
||||||
|
participant "Config" as config
|
||||||
|
participant "SABnzbd\n(per instance)" as sab
|
||||||
|
participant "Sonarr\n(per instance)" as sonarr
|
||||||
|
participant "Radarr\n(per instance)" as radarr
|
||||||
|
participant "qBittorrent\nClient" as qbt
|
||||||
|
participant "MemoryCache" as cache
|
||||||
|
|
||||||
|
== Startup ==
|
||||||
|
entry -> poller : startPoller()
|
||||||
|
activate poller
|
||||||
|
|
||||||
|
alt POLL_INTERVAL > 0
|
||||||
|
poller -> poller : pollAllServices() (immediate)
|
||||||
|
poller -> poller : setInterval(pollAllServices,\nPOLL_INTERVAL)
|
||||||
|
else POLL_INTERVAL = 0
|
||||||
|
poller --> entry : "Polling disabled, on-demand mode"
|
||||||
|
end
|
||||||
|
|
||||||
|
== Poll Cycle ==
|
||||||
|
poller -> poller : Check: polling flag?\n(skip if concurrent)
|
||||||
|
poller -> poller : polling = true
|
||||||
|
poller -> poller : start = Date.now()
|
||||||
|
|
||||||
|
poller -> config : getSABnzbdInstances()
|
||||||
|
config --> poller : [{ id, url, apiKey }]
|
||||||
|
poller -> config : getSonarrInstances()
|
||||||
|
config --> poller : [{ id, url, apiKey }]
|
||||||
|
poller -> config : getRadarrInstances()
|
||||||
|
config --> poller : [{ id, url, apiKey }]
|
||||||
|
|
||||||
|
note over poller : All fetches run in\nparallel via Promise.all,\neach wrapped in timed()
|
||||||
|
|
||||||
|
par SABnzbd Queue
|
||||||
|
poller -> sab : GET /api?mode=queue
|
||||||
|
sab --> poller : { queue: { slots, status, speed } }
|
||||||
|
and SABnzbd History
|
||||||
|
poller -> sab : GET /api?mode=history&limit=10
|
||||||
|
sab --> poller : { history: { slots } }
|
||||||
|
and Sonarr Tags
|
||||||
|
poller -> sonarr : GET /api/v3/tag
|
||||||
|
sonarr --> poller : [{ id, label }]
|
||||||
|
and Sonarr Queue
|
||||||
|
poller -> sonarr : GET /api/v3/queue\n?includeSeries=true
|
||||||
|
sonarr --> poller : { records: [{ seriesId, series, ... }] }
|
||||||
|
and Sonarr History
|
||||||
|
poller -> sonarr : GET /api/v3/history\n?pageSize=10
|
||||||
|
sonarr --> poller : { records: [{ seriesId, ... }] }
|
||||||
|
and Radarr Queue
|
||||||
|
poller -> radarr : GET /api/v3/queue\n?includeMovie=true
|
||||||
|
radarr --> poller : { records: [{ movieId, movie, ... }] }
|
||||||
|
and Radarr History
|
||||||
|
poller -> radarr : GET /api/v3/history\n?pageSize=10
|
||||||
|
radarr --> poller : { records: [{ movieId, ... }] }
|
||||||
|
and Radarr Tags
|
||||||
|
poller -> radarr : GET /api/v3/tag
|
||||||
|
radarr --> poller : [{ id, label }]
|
||||||
|
and qBittorrent
|
||||||
|
poller -> qbt : getTorrents()
|
||||||
|
qbt --> poller : [{ name, progress, ... }]
|
||||||
|
end
|
||||||
|
|
||||||
|
poller -> poller : Record per-task timings\nlastPollTimings = { totalMs,\ntimestamp, tasks: [{label, ms}] }
|
||||||
|
|
||||||
|
poller -> poller : cacheTTL = POLL_INTERVAL × 3
|
||||||
|
|
||||||
|
poller -> cache : set('poll:sab-queue', ..., cacheTTL)
|
||||||
|
poller -> cache : set('poll:sab-history', ..., cacheTTL)
|
||||||
|
poller -> cache : set('poll:sonarr-tags', ..., cacheTTL)
|
||||||
|
|
||||||
|
note over poller : Tag queue records with\n_instanceUrl on embedded\nseries/movie objects
|
||||||
|
|
||||||
|
poller -> cache : set('poll:sonarr-queue', ..., cacheTTL)
|
||||||
|
poller -> cache : set('poll:sonarr-history', ..., cacheTTL)
|
||||||
|
poller -> cache : set('poll:radarr-queue', ..., cacheTTL)
|
||||||
|
poller -> cache : set('poll:radarr-history', ..., cacheTTL)
|
||||||
|
poller -> cache : set('poll:radarr-tags', ..., cacheTTL)
|
||||||
|
poller -> cache : set('poll:qbittorrent', ..., cacheTTL)
|
||||||
|
|
||||||
|
poller -> poller : polling = false\nlog elapsed time
|
||||||
|
|
||||||
|
deactivate poller
|
||||||
|
|
||||||
|
@enduml
|
||||||
65
docs/diagrams/state-poller.puml
Normal file
65
docs/diagrams/state-poller.puml
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
@startuml state-poller
|
||||||
|
!theme plain
|
||||||
|
title sofarr — Poller State Diagram
|
||||||
|
|
||||||
|
[*] --> CheckConfig : startPoller()
|
||||||
|
|
||||||
|
state CheckConfig <<choice>>
|
||||||
|
CheckConfig --> Disabled : POLL_INTERVAL = 0\nor 'off' / 'false'
|
||||||
|
CheckConfig --> Idle : POLL_INTERVAL > 0
|
||||||
|
|
||||||
|
state Disabled {
|
||||||
|
state "On-demand mode\nNo background timer" as od
|
||||||
|
od : Data fetched only when\na dashboard request\nfinds empty cache
|
||||||
|
}
|
||||||
|
|
||||||
|
Disabled --> Polling : pollAllServices()\n(triggered by dashboard request)
|
||||||
|
Polling --> Disabled : Poll complete\n(return to on-demand)
|
||||||
|
|
||||||
|
state Idle {
|
||||||
|
state "Waiting for\nnext interval" as waiting
|
||||||
|
}
|
||||||
|
|
||||||
|
Idle --> Polling : setInterval fires\nor immediate first poll
|
||||||
|
|
||||||
|
state Polling {
|
||||||
|
state "polling = true" as lock
|
||||||
|
state "Fetching all services\n(Promise.all)" as fetching
|
||||||
|
state "Storing results\nin cache" as storing
|
||||||
|
state "Recording timings" as timing
|
||||||
|
|
||||||
|
[*] --> lock
|
||||||
|
lock --> fetching
|
||||||
|
fetching --> storing : All promises resolved
|
||||||
|
fetching --> ErrorState : Any individual service\nerror (caught per-service)
|
||||||
|
storing --> timing
|
||||||
|
timing --> [*] : polling = false
|
||||||
|
}
|
||||||
|
|
||||||
|
state ErrorState as "Handle Error" {
|
||||||
|
state "Log error\npolling = false" as err
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrorState --> Idle : Next interval
|
||||||
|
Polling --> Idle : Poll complete\n(back to waiting)
|
||||||
|
|
||||||
|
state "Concurrent Poll\nAttempt" as skip {
|
||||||
|
state "polling === true\n→ skip" as sk
|
||||||
|
}
|
||||||
|
|
||||||
|
Idle --> skip : Interval fires while\nprevious still running
|
||||||
|
skip --> Idle : Log "still running,\nskipping"
|
||||||
|
|
||||||
|
note right of Polling
|
||||||
|
**Cache TTL**: POLL_INTERVAL × 3
|
||||||
|
Ensures data survives between polls
|
||||||
|
even if one cycle is slow.
|
||||||
|
end note
|
||||||
|
|
||||||
|
note right of Disabled
|
||||||
|
**Cache TTL**: 30000ms (30s)
|
||||||
|
After expiry, next dashboard
|
||||||
|
request triggers a fresh poll.
|
||||||
|
end note
|
||||||
|
|
||||||
|
@enduml
|
||||||
79
docs/diagrams/state-ui.puml
Normal file
79
docs/diagrams/state-ui.puml
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
@startuml state-ui
|
||||||
|
!theme plain
|
||||||
|
title sofarr — Frontend UI State Diagram
|
||||||
|
|
||||||
|
[*] --> SplashScreen : Page load
|
||||||
|
|
||||||
|
state SplashScreen {
|
||||||
|
state "Showing splash\n(min 1.2s)" as showing
|
||||||
|
}
|
||||||
|
|
||||||
|
SplashScreen --> CheckAuth : checkAuthentication()
|
||||||
|
|
||||||
|
state CheckAuth <<choice>>
|
||||||
|
CheckAuth --> LoginForm : No session cookie
|
||||||
|
CheckAuth --> Dashboard : Valid session
|
||||||
|
|
||||||
|
state LoginForm {
|
||||||
|
state "Idle" as lf_idle
|
||||||
|
state "Submitting" as lf_submit
|
||||||
|
state "Error" as lf_error
|
||||||
|
|
||||||
|
lf_idle --> lf_submit : Submit form
|
||||||
|
lf_submit --> lf_error : Auth failed
|
||||||
|
lf_error --> lf_submit : Re-submit
|
||||||
|
lf_submit --> FadeOutLogin : Auth success
|
||||||
|
}
|
||||||
|
|
||||||
|
state FadeOutLogin {
|
||||||
|
state "CSS transition\n(opacity → 0)" as fade
|
||||||
|
}
|
||||||
|
|
||||||
|
FadeOutLogin --> SplashScreen2 : Show splash\nwhile loading
|
||||||
|
|
||||||
|
state SplashScreen2 as "Splash (loading data)" {
|
||||||
|
state "fetchUserDownloads()" as fetching
|
||||||
|
}
|
||||||
|
|
||||||
|
SplashScreen2 --> Dashboard : Data loaded\ndismissSplash()
|
||||||
|
|
||||||
|
state Dashboard {
|
||||||
|
state "Rendering Cards" as rendering
|
||||||
|
state "Auto Refreshing" as refreshing
|
||||||
|
state "Status Panel Open" as status_open
|
||||||
|
state "Status Panel Closed" as status_closed
|
||||||
|
|
||||||
|
[*] --> rendering
|
||||||
|
rendering --> refreshing : startAutoRefresh()
|
||||||
|
refreshing --> rendering : fetchUserDownloads()\n→ renderDownloads()
|
||||||
|
rendering --> rendering : Theme change
|
||||||
|
|
||||||
|
status_closed --> status_open : Click "Status" btn\n(admin only)
|
||||||
|
status_open --> status_closed : Click close (×)
|
||||||
|
status_open --> status_open : Auto-refresh\nrenderStatusPanel()
|
||||||
|
|
||||||
|
[*] --> status_closed
|
||||||
|
|
||||||
|
state "Refresh Rate" as rr {
|
||||||
|
state "1s" as r1
|
||||||
|
state "5s (default)" as r5
|
||||||
|
state "10s" as r10
|
||||||
|
state "Off" as roff
|
||||||
|
r5 --> r1 : User selects
|
||||||
|
r5 --> r10
|
||||||
|
r5 --> roff
|
||||||
|
r1 --> r5
|
||||||
|
r1 --> r10
|
||||||
|
r1 --> roff
|
||||||
|
r10 --> r1
|
||||||
|
r10 --> r5
|
||||||
|
r10 --> roff
|
||||||
|
roff --> r1
|
||||||
|
roff --> r5
|
||||||
|
roff --> r10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Dashboard --> LoginForm : Logout\n(clear cookie,\nstopAutoRefresh)
|
||||||
|
|
||||||
|
@enduml
|
||||||
Reference in New Issue
Block a user