Files
sofarr/docs/ARCHITECTURE.md
Gronod 36d183cba9
All checks were successful
Build and Push Docker Image / build (push) Successful in 23s
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
2026-05-16 00:30:38 +01:00

24 KiB
Raw Blame History

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
  2. Technology Stack
  3. Directory Structure
  4. Component Architecture
  5. Data Flow
  6. Authentication & Authorisation
  7. Background Polling & Caching
  8. Download Matching Pipeline
  9. API Reference
  10. Frontend Architecture
  11. Configuration
  12. Deployment
  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.jsQBittorrentClient class with cookie-based authentication, automatic re-auth on 403, and persistent client instances. Includes torrent-to-download mapping (mapTorrentToDownload) and formatting utilities.

logger.js — Simple file appender writing timestamped messages to server.log.


5. Data Flow

5.1 Polling Cycle

Every POLL_INTERVAL ms (default 5000), the poller fetches from all services in parallel:

Task API Call Params
SABnzbd Queue GET /api?mode=queue output=json
SABnzbd History GET /api?mode=history limit=10
Sonarr Tags GET /api/v3/tag
Sonarr Queue GET /api/v3/queue includeSeries=true
Sonarr History GET /api/v3/history pageSize=10
Radarr Queue GET /api/v3/queue includeMovie=true
Radarr History GET /api/v3/history pageSize=10
Radarr Tags GET /api/v3/tag
qBittorrent GET /api/v2/torrents/info

Results are stored in the cache under poll:* keys with a TTL of POLL_INTERVAL × 3.

5.2 Dashboard Request

When a user requests /api/dashboard/user-downloads:

  1. Read all poll:* keys from cache
  2. Build seriesMap and moviesMap from embedded objects in queue records
  3. Build sonarrTagMap and radarrTagMap from tag data
  4. For each SABnzbd queue slot → try to match against Sonarr/Radarr queue records by title
  5. For each SABnzbd history slot → try to match against Sonarr/Radarr history records
  6. For each qBittorrent torrent → try to match against Sonarr/Radarr queue, then history
  7. For each match, resolve the series/movie, extract the user tag, check if it belongs to the requesting user
  8. Return only the user's downloads (or all, if admin with showAll=true)

6. Authentication & Authorisation

Flow

  1. User submits credentials via the login form
  2. Backend calls Emby POST /Users/authenticatebyname
  3. On success, fetches full user profile to determine admin status
  4. Sets an httpOnly cookie (emby_user) containing: { id, name, isAdmin, token }
  5. Cookie expires after 24 hours
  6. All subsequent dashboard requests read this cookie for identity

Authorisation Matrix

Feature Regular User Admin
View own downloads
View all users' downloads ✓ (showAll)
See download/target paths
See Sonarr/Radarr links
View status panel

Tag Matching

Users are matched to downloads via tags in Sonarr/Radarr:

  1. Exact match: tag label (lowercased) === username (lowercased)
  2. Sanitised match: handles Ombi's tag mangling — sanitizeTagLabel() converts to lowercase, replaces non-alphanumeric with hyphens, collapses, trims

7. Background Polling & Caching

Polling Modes

Mode POLL_INTERVAL Behaviour
Background > 0 (e.g. 5000) Periodic fetch every N ms
On-demand 0 / off / false Fetch triggered by first user request when cache is empty

Cache Keys

Key Content Source
poll:sab-queue { slots, status, speed, kbpersec } SABnzbd queue
poll:sab-history { slots } SABnzbd history
poll:sonarr-tags [{ instance, data: [{id, label}] }] Sonarr tag API
poll:sonarr-queue { records } — includes embedded series objects Sonarr queue (includeSeries)
poll:sonarr-history { records } — lightweight, no embedded objects Sonarr history
poll:radarr-queue { records } — includes embedded movie objects Radarr queue (includeMovie)
poll:radarr-history { records } — lightweight, no embedded objects Radarr history
poll:radarr-tags [{id, label}] Radarr tag API
poll:qbittorrent [torrent, ...] qBittorrent all torrents

TTL Strategy

  • Polling enabled: POLL_INTERVAL × 3 — ensures data survives between polls even if one poll is slow
  • Polling disabled: 30000 ms — stale after 30s, next request triggers a fresh fetch

Active Client Tracking

Each dashboard request reports the client's refresh rate. The server tracks active clients in a Map<username, { user, refreshRateMs, lastSeen }>, pruning entries older than 30 seconds. This data powers the admin status panel's "effective refresh mode" display.


8. Download Matching Pipeline

The core logic in dashboard.js matches raw download client data to *arr service metadata to determine which user each download belongs to.

Matching Strategy

For each download item (SABnzbd slot or qBittorrent torrent):

1. Try Sonarr QUEUE match (by title substring)
   → resolve series via seriesMap (embedded in queue record)
   → extract user tag → check tag matches requesting user

2. Try Radarr QUEUE match (by title substring)
   → resolve movie via moviesMap (embedded in queue record)
   → extract user tag → check tag matches requesting user

3. Try Sonarr HISTORY match (by title substring)
   → resolve series via seriesMap (from queue) using seriesId
   → extract user tag → check tag matches requesting user

4. Try Radarr HISTORY match (by title substring)
   → resolve movie via moviesMap (from queue) using movieId
   → extract user tag → check tag matches requesting user

Title Matching

Matches are bidirectional substring matches (case-insensitive):

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

See diagrams/component.puml

13.2 Sequence Diagrams

13.3 Class / Entity Diagrams

13.4 State Diagrams

13.5 Activity Diagram