# 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 │ ├── middleware/ │ │ └── requireAuth.js # httpOnly cookie auth middleware │ └── 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 │ ├── favicon.ico # Multi-size favicon (16/32/48px) │ ├── favicon-32.png # 32px PNG favicon │ ├── favicon-192.png # 192px PNG (apple-touch-icon / PWA) │ └── images/ # Logo / splash screen assets ├── 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 (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 (public) | Login, session check, logout | | `dashboard.js` | `/api/dashboard` | Yes (`requireAuth`) | Aggregated download data, status | | `emby.js` | `/api/emby` | Yes (`requireAuth`) | Proxy to Emby API | | `sabnzbd.js` | `/api/sabnzbd` | Yes (`requireAuth`) | Proxy to SABnzbd API | | `sonarr.js` | `/api/sonarr` | Yes (`requireAuth`) | Proxy to Sonarr API | | `radarr.js` | `/api/radarr` | Yes (`requireAuth`) | Proxy to Radarr API | `requireAuth` (`server/middleware/requireAuth.js`) reads the `emby_user` httpOnly cookie and attaches the parsed user to `req.user`. Returns `401` if the cookie is absent or malformed. > **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 }` — the Emby `AccessToken` is intentionally **not** stored in the cookie 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 | | `emby:users` | `Map` | Full Emby user list (60s TTL) | ### 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 | | `allTags` | string[] | All resolved tag labels on the series/movie | | `matchedUserTag` | string / null | Tag label matching the requesting user, or `null` | | `tagBadges` | `{label, matchedUser}[]` / undefined | (Admin `showAll` only) Each tag classified against full Emby user list | | `importIssues` | string[] / null | Import warning/error messages | | `downloadPath` | string / null | (Admin) Download client path | | `targetPath` | string / null | (Admin) *arr target path | | `arrLink` | string / null | (Admin) Link to *arr web UI | --- ## 9. API Reference ### `POST /api/auth/login` Authenticate a user via Emby. **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, `httpOnly`, `secure` in production, `sameSite: strict`). Cookie payload: `{ id, name, isAdmin }` — Emby `AccessToken` is not stored. --- ### `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`, 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; renders tag badges | | `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`. ### Tag Badge Rendering Download cards render tag badges in the card header: - **Normal user view**: a single accent-coloured badge showing the tag label that matched the current user's username (via `matchedUserTag`). - **Admin `showAll` view**: all tags on the download are rendered using `tagBadges[]`: - Tags with **no matching Emby user** → amber badge showing the raw tag label (leftmost) - Tags **matched to a known Emby user** → accent badge showing the Emby display name (rightmost) ### 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)