- 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
24 KiB
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
- System Overview
- Technology Stack
- Directory Structure
- Component Architecture
- Data Flow
- Authentication & Authorisation
- Background Polling & Caching
- Download Matching Pipeline
- API Reference
- Frontend Architecture
- Configuration
- Deployment
- 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:
- Authenticating users against an Emby/Jellyfin media server.
- Aggregating download data from multiple *arr service instances and download clients.
- Filtering downloads by user — each user only sees media tagged with their username in Sonarr/Radarr.
- 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 andserver.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:
- Read all
poll:*keys from cache - Build
seriesMapandmoviesMapfrom embedded objects in queue records - Build
sonarrTagMapandradarrTagMapfrom tag data - For each SABnzbd queue slot → try to match against Sonarr/Radarr queue records by title
- For each SABnzbd history slot → try to match against Sonarr/Radarr history records
- For each qBittorrent torrent → try to match against Sonarr/Radarr queue, then history
- For each match, resolve the series/movie, extract the user tag, check if it belongs to the requesting user
- Return only the user's downloads (or all, if admin with
showAll=true)
6. Authentication & Authorisation
Flow
- User submits credentials via the login form
- Backend calls Emby
POST /Users/authenticatebyname - On success, fetches full user profile to determine admin status
- Sets an
httpOnlycookie (emby_user) containing:{ id, name, isAdmin, token } - Cookie expires after 24 hours
- 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:
- Exact match: tag label (lowercased) === username (lowercased)
- 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:
30000ms — 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):
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:
{ "username": "string", "password": "string" }
Response (200):
{
"success": true,
"user": { "id": "string", "name": "string", "isAdmin": true }
}
Response (401):
{ "success": false, "error": "Invalid username or password" }
Side Effect: Sets emby_user httpOnly cookie (24h TTL).
GET /api/auth/me
Check current session.
Response:
{
"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):
{
"user": "string",
"isAdmin": true,
"downloads": [ /* download objects */ ]
}
GET /api/dashboard/status
Admin-only server status.
Response (200):
{
"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):
[
{ "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
[
{
"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
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
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
13.2 Sequence Diagrams
- Authentication:
diagrams/seq-auth.puml - Dashboard Request:
diagrams/seq-dashboard.puml - Polling Cycle:
diagrams/seq-polling.puml
13.3 Class / Entity Diagrams
- Server Classes:
diagrams/class-server.puml - Data Model:
diagrams/class-data.puml
13.4 State Diagrams
- Frontend UI States:
diagrams/state-ui.puml - Poller States:
diagrams/state-poller.puml
13.5 Activity Diagram
- Download Matching:
diagrams/activity-matching.puml