From e97bd3c67b3b18d0d340e656c0ff628ccf64f856 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 16 May 2026 00:30:38 +0100 Subject: [PATCH] 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 --- docs/ARCHITECTURE.md | 609 +++++++++++++++++++++++++++ docs/diagrams/activity-matching.puml | 128 ++++++ docs/diagrams/class-data.puml | 221 ++++++++++ docs/diagrams/class-server.puml | 197 +++++++++ docs/diagrams/component.puml | 94 +++++ docs/diagrams/seq-auth.puml | 67 +++ docs/diagrams/seq-dashboard.puml | 85 ++++ docs/diagrams/seq-polling.puml | 89 ++++ docs/diagrams/state-poller.puml | 65 +++ docs/diagrams/state-ui.puml | 79 ++++ 10 files changed, 1634 insertions(+) create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/diagrams/activity-matching.puml create mode 100644 docs/diagrams/class-data.puml create mode 100644 docs/diagrams/class-server.puml create mode 100644 docs/diagrams/component.puml create mode 100644 docs/diagrams/seq-auth.puml create mode 100644 docs/diagrams/seq-dashboard.puml create mode 100644 docs/diagrams/seq-polling.puml create mode 100644 docs/diagrams/state-poller.puml create mode 100644 docs/diagrams/state-ui.puml diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..e35a2c4 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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`, 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 ``: +- **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) diff --git a/docs/diagrams/activity-matching.puml b/docs/diagrams/activity-matching.puml new file mode 100644 index 0000000..c74b956 --- /dev/null +++ b/docs/diagrams/activity-matching.puml @@ -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 diff --git a/docs/diagrams/class-data.puml b/docs/diagrams/class-data.puml new file mode 100644 index 0000000..ed92cf4 --- /dev/null +++ b/docs/diagrams/class-data.puml @@ -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 diff --git a/docs/diagrams/class-server.puml b/docs/diagrams/class-server.puml new file mode 100644 index 0000000..7e7042b --- /dev/null +++ b/docs/diagrams/class-server.puml @@ -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 <> { + - 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 <> { + + POST /login + + GET /me + + POST /logout + -- + Authenticates via Emby API + Sets/reads httpOnly cookie + } + + class "dashboard.js" as dashboard <> { + - activeClients : Map + - 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 <> { + + GET /sessions + + GET /users/:id + + GET /users + + GET /session/:sessionId/user + } + + class "sabnzbd.js" as sab_r <> { + + GET /queue + + GET /history + } + + class "sonarr.js" as sonarr_r <> { + + GET /queue + + GET /history + + GET /series/:id + + GET /series + } + + class "radarr.js" as radarr_r <> { + + GET /queue + + GET /history + + GET /movies/:id + + GET /movies + } +} + +package "server/utils" { + class "MemoryCache" as cache { + - store : Map + + get(key) : any|null + + set(key, value, ttlMs) : void + + invalidate(key) : void + + clear() : void + + getStats() : CacheStats + } + + class "CacheEntry" as ce <> { + + value : any + + expiresAt : number + } + + class "CacheStats" as cs <> { + + entryCount : number + + totalSizeBytes : number + + entries : CacheEntryStats[] + } + + class "Poller" as poller <> { + - POLL_INTERVAL : number + - POLLING_ENABLED : boolean + - polling : boolean + - lastPollTimings : PollTimings|null + - intervalHandle : number|null + -- + + startPoller() : void + + stopPoller() : void + + pollAllServices() : Promise + + getLastPollTimings() : PollTimings|null + -- + - timed(label, fn) : TimedResult + } + + class "PollTimings" as pt <> { + + totalMs : number + + timestamp : string (ISO) + + tasks : { label, ms }[] + } + + class "Config" as config <> { + + getSABnzbdInstances() : Instance[] + + getSonarrInstances() : Instance[] + + getRadarrInstances() : Instance[] + + getQbittorrentInstances() : Instance[] + -- + - parseInstances(envVar, ...) : Instance[] + } + + class "Instance" as inst <> { + + 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 + + makeRequest(endpoint, config) : Promise + + getTorrents() : Promise + } + + class "qbittorrent.js" as qbt_mod <> { + - persistedClients : QBittorrentClient[]|null + -- + + getTorrents() : Promise + + getClients() : QBittorrentClient[] + + mapTorrentToDownload(torrent) : Download + + formatBytes(bytes) : string + + formatSpeed(bps) : string + + formatEta(seconds) : string + } + + class "Logger" as logger <> { + - logFile : WriteStream + + logToFile(message) : void + } + + class "ClientInfo" as ci <> { + + 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 diff --git a/docs/diagrams/component.puml b/docs/diagrams/component.puml new file mode 100644 index 0000000..61f5191 --- /dev/null +++ b/docs/diagrams/component.puml @@ -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 diff --git a/docs/diagrams/seq-auth.puml b/docs/diagrams/seq-auth.puml new file mode 100644 index 0000000..f81c644 --- /dev/null +++ b/docs/diagrams/seq-auth.puml @@ -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 diff --git a/docs/diagrams/seq-dashboard.puml b/docs/diagrams/seq-dashboard.puml new file mode 100644 index 0000000..98864dc --- /dev/null +++ b/docs/diagrams/seq-dashboard.puml @@ -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 diff --git a/docs/diagrams/seq-polling.puml b/docs/diagrams/seq-polling.puml new file mode 100644 index 0000000..5014f2d --- /dev/null +++ b/docs/diagrams/seq-polling.puml @@ -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 diff --git a/docs/diagrams/state-poller.puml b/docs/diagrams/state-poller.puml new file mode 100644 index 0000000..e3bf585 --- /dev/null +++ b/docs/diagrams/state-poller.puml @@ -0,0 +1,65 @@ +@startuml state-poller +!theme plain +title sofarr — Poller State Diagram + +[*] --> CheckConfig : startPoller() + +state CheckConfig <> +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 diff --git a/docs/diagrams/state-ui.puml b/docs/diagrams/state-ui.puml new file mode 100644 index 0000000..5642922 --- /dev/null +++ b/docs/diagrams/state-ui.puml @@ -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 <> +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