Files
sofarr/docs/ARCHITECTURE.md
Gronod a42392fec6
Some checks failed
Build and Push Docker Image / build (push) Successful in 34s
CI / Tests & coverage (push) Has been cancelled
CI / Security audit (push) Has been cancelled
Docs Check / Markdown lint (push) Failing after 31s
Docs Check / Mermaid diagram parse check (push) Failing after 2m38s
ci: trigger docs-check workflow
2026-05-17 18:36:58 +01:00

55 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. Diagrams (Mermaid)

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

flowchart TB
    subgraph Browser["Browser (SPA)"]
        login["Login Form"]
        dash["Dashboard Cards"]
        status["Status Panel\n(Admin only)"]
    end

    subgraph Server["Express Server (:3001)"]
        auth_r["Auth Routes\n/api/auth"]
        dash_r["Dashboard Routes\n/api/dashboard"]
        emby_r["Emby Routes\n/api/emby"]
        static_f["Static Files\npublic/"]

        subgraph Utils["Utilities Layer"]
            poller["Poller"]
            cache["Cache"]
            config["Config"]
            qbt["qBittorrent"]
        end
    end

    subgraph Ext["External Services"]
        sab["SABnzbd\n(Usenet)"]
        sonarr["Sonarr\n(TV)"]
        radarr["Radarr\n(Movies)"]
        qbittorrent["qBittorrent\n(Torrent)"]
        emby["Emby / Jellyfin\n(Auth + User DB)"]
    end

    login -->|"POST /login"| auth_r
    dash  -->|"GET /stream SSE\nGET /user-downloads"| dash_r
    status -->|"GET /status"| dash_r

    auth_r -->|"authenticate"| emby
    emby_r -->|"proxy"| emby
    dash_r --> Utils
    poller -->|"HTTP/API calls"| sab & sonarr & radarr
    qbt    -->|"HTTP/API calls"| qbittorrent
    static_f -.->|"serve"| Browser

2. Technology Stack

Runtime & Framework

Layer Technology Purpose
Runtime Node.js 22 (Alpine) 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
Containerisation Docker multi-stage (Alpine) Production deployment
Logging Custom logger + console.* File + stdout logging with levels

Security Middleware

Package Purpose
helmet 7.x HTTP security headers (CSP with per-request nonce, HSTS, referrer policy)
express-rate-limit 7.x 300 req/15 min general limiter; 10 failed attempts/15 min login limiter
cookie-parser 1.x Signed cookie support (HMAC via COOKIE_SECRET)

Auth & Session

Component Technology Details
Identity Emby API POST /Users/authenticatebyname
Session cookie httpOnly + sameSite: strict Signed when COOKIE_SECRET set
CSRF protection Double-submit cookie pattern csrf_token cookie + X-CSRF-Token header
Token store JSON file (DATA_DIR/tokens.json) Atomic writes, 31-day TTL, hourly pruning

Testing

Tool Purpose
vitest 4.x Test runner (V8 coverage built-in)
supertest 7.x HTTP integration testing
nock 14.x HTTP interception at Node layer (works with CJS require('axios'))

3. Directory Structure

sofarr/
├── server/                     # Backend application
│   ├── index.js                # Entry point: logging setup, server listen, poller start
│   ├── app.js                  # Express app factory (imported by index.js and tests)
│   ├── routes/
│   │   ├── auth.js             # POST /login, GET /me, GET /csrf, POST /logout
│   │   ├── dashboard.js        # GET /user-downloads, /user-summary, /status, /cover-art
│   │   ├── 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
│   │   └── history.js          # GET /api/history/recent — recently completed downloads
│   ├── middleware/
│   │   ├── requireAuth.js      # httpOnly cookie auth enforcement
│   │   └── verifyCsrf.js       # CSRF double-submit cookie validation
│   └── utils/
│       ├── cache.js            # MemoryCache class (Map + TTL + stats)
│       ├── config.js           # Multi-instance service configuration parser
│       ├── historyFetcher.js   # Fetch + cache Sonarr/Radarr history; event classification
│       ├── logger.js           # File logger (DATA_DIR/server.log)
│       ├── poller.js           # Background polling engine + timing
│       ├── qbittorrent.js      # qBittorrent client with auth + torrent mapping
│       ├── sanitizeError.js    # Redacts secrets from error messages before logging
│       └── tokenStore.js       # JSON file-backed Emby token store (atomic, TTL)
├── 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
├── tests/
│   ├── README.md               # Testing approach, design decisions, coverage targets
│   ├── setup.js                # Global setup: isolated DATA_DIR, rate-limit bypass
│   ├── unit/                   # Pure unit tests (no HTTP)
│   └── integration/            # Supertest integration tests (nock for external HTTP)
├── docs/
│   ├── ARCHITECTURE.md         # This document
│   └── diagrams/               # PlantUML source files
├── .gitea/workflows/
│   ├── ci.yml                  # Security audit + test/coverage CI jobs
│   ├── build-image.yml         # Docker image build and push
│   └── create-release.yml      # Release tagging workflow
├── Dockerfile                  # Multi-stage production container image (node:22-alpine)
├── docker-compose.yaml         # Example compose deployment
├── vitest.config.js            # Test runner configuration with per-file coverage thresholds
├── package.json                # Dependencies and scripts
├── .env.sample                 # Annotated environment variable template
└── README.md                   # User-facing documentation

4. Component Architecture

4.1 Application Factory (server/app.js) and Entry Point (server/index.js)

server/app.js is a createApp(options?) factory that builds and returns the configured Express app instance. It is imported by both server/index.js (production) and the test suite. Keeping app creation separate from app.listen() means tests get a clean instance without triggering log-file setup, process.exit() calls, or the background poller.

createApp responsibilities:

  • Configure trust proxy from TRUST_PROXY env var
  • Apply helmet with a per-request CSP nonce (crypto.randomBytes(16)) for inline styles/scripts
  • Add Permissions-Policy header
  • Apply the general API rate limiter (300 req / 15 min per IP)
  • Mount cookie-parser (signed when COOKIE_SECRET is set)
  • Mount express.json (64 KB body limit)
  • Expose /health and /ready endpoints (no auth, no rate limit)
  • Mount /api/auth routes before CSRF middleware (login/logout are exempt)
  • Mount verifyCsrf for all subsequent /api routes
  • Mount remaining route modules under /api/*
  • Register global error handler (500 with sanitized message)

server/index.js entry point responsibilities:

  • Load .env via dotenv
  • Configure structured logging (LOG_LEVEL) with console.* redirection to both stdout and DATA_DIR/server.log
  • Call createApp(), serve public/ as static files, start app.listen()
  • Start the background poller

4.2 Route Modules

Module Mount Point Auth Required CSRF Required Purpose
auth.js /api/auth No No Login, session check, CSRF token, logout
dashboard.js /api/dashboard Yes (requireAuth) Yes (except /stream — GET) SSE stream, aggregated download data, status, cover-art proxy
emby.js /api/emby Yes (requireAuth) Yes Proxy to Emby API
sabnzbd.js /api/sabnzbd Yes (requireAuth) Yes Proxy to SABnzbd API
sonarr.js /api/sonarr Yes (requireAuth) Yes Proxy to Sonarr API
radarr.js /api/radarr Yes (requireAuth) Yes Proxy to Radarr API
history.js /api/history Yes (requireAuth) No (GET only) Recently completed downloads from Sonarr/Radarr history

requireAuth (server/middleware/requireAuth.js) reads the emby_user cookie (signed if COOKIE_SECRET is set) and attaches the parsed { id, name, isAdmin } user to req.user. Returns 401 if the cookie is absent, tampered, or schema-invalid.

verifyCsrf (server/middleware/verifyCsrf.js) implements the double-submit cookie pattern for all state-changing requests (POST, PUT, PATCH, DELETE). Compares the csrf_token cookie against the X-CSRF-Token request header using crypto.timingSafeEqual. Safe methods (GET, HEAD, OPTIONS) are exempt. Auth routes are mounted before this middleware (login/logout are exempt by design — sameSite: strict provides equivalent protection).

Note: The proxy routes (emby, sabnzbd, sonarr, radarr) exist for direct API access. The dashboard itself reads from the poller cache — it does not proxy through these routes.

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. After each successful poll it notifies all registered SSE subscriber callbacks so connected clients receive data immediately.

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 (formatBytes, formatSpeed, formatEta).

tokenStore.js — JSON file-backed store (DATA_DIR/tokens.json) for Emby AccessTokens. Tokens are stored server-side and never sent to the client. Writes are atomic (write to .tmp then rename). Entries expire after 31 days (slightly longer than the maximum 30-day cookie). Pruning runs on startup and hourly.

sanitizeError.js — Redacts secrets from error message strings before they are logged or returned in API responses. Patterns: URL query-param secrets (apikey=, token=, etc.), HTTP auth headers (Authorization:, X-Emby-Authorization:, etc.), Bearer tokens, and basic-auth credentials in URLs.

historyFetcher.js — Fetches history records from all Sonarr/Radarr instances for a configurable date window (since). Results are cached under history:sonarr / history:radarr for 5 minutes. Exports classifySonarrEvent / classifyRadarrEvent (returns 'imported' | 'failed' | 'other') and invalidateHistoryCache.

logger.js — Simple file appender writing timestamped messages to DATA_DIR/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, includeEpisode=true
Sonarr History GET /api/v3/history pageSize=10, includeEpisode=true
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 SSE Stream

When a browser opens GET /api/dashboard/stream (after authentication):

  1. Server sets Content-Type: text/event-stream, disables buffering (X-Accel-Buffering: no)
  2. Immediately builds and sends the first payload (same matching logic as below)
  3. Registers a callback with the poller's onPollComplete subscriber set
  4. After every subsequent poll cycle completes the callback fires, rebuilds the payload, and writes a data: SSE frame
  5. A 25-second heartbeat comment (: heartbeat) keeps the connection alive through proxies
  6. On client disconnect: deregisters callback, stops heartbeat, removes from active-client map

The browser's native EventSource API handles reconnection automatically on network interruption.

5.3 Download Matching

For each connected user the server:

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

6. Authentication & Authorisation

Flow

  1. User submits credentials (+ optional rememberMe) via the login form
  2. Backend applies the login rate limiter (max 10 failed attempts per IP per 15-minute window; successful requests don't count)
  3. Backend calls Emby POST /Users/authenticatebyname using a deterministic DeviceId derived from the username — this causes Emby to reuse the existing session rather than creating a new one on every login
  4. On success, fetches full user profile (GET /Users/{id}) to determine admin status
  5. Stores the Emby AccessToken in tokenStore (server-side, never sent to the client)
  6. Sets an httpOnly emby_user cookie containing only { id, name, isAdmin }:
    • rememberMe: true → persistent cookie, Max-Age 30 days
    • rememberMe: false → session cookie (no Max-Age; expires when browser closes)
    • secure flag enabled when TRUST_PROXY is set (i.e. a TLS-terminating reverse proxy is in front)
    • Signed with HMAC when COOKIE_SECRET is set
  7. Issues a csrf_token cookie (httpOnly: false so JS can read it) containing a 32-byte random hex token
  8. Returns { success: true, user, csrfToken } — the SPA stores csrfToken in memory and sends it as X-CSRF-Token on all subsequent state-changing requests
  9. All subsequent dashboard requests read the emby_user cookie for identity via requireAuth

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<lowerName, displayName> Full Emby user list (60s TTL)
history:sonarr [record, ...] — flat array with _instanceUrl / _instanceName Sonarr history (5 min TTL, fetched on-demand by /api/history/recent)
history:radarr [record, ...] — flat array with _instanceUrl / _instanceName Radarr history (5 min TTL, fetched on-demand by /api/history/recent)

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

SSE connections are tracked precisely: registered on connect, removed on disconnect. The admin status panel shows each connected user and how long they have been connected. The type: 'sse' field distinguishes SSE clients from any legacy HTTP clients.


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):

flowchart TD
    Start(["Download item"]) --> SQ{"Sonarr QUEUE\nmatch (title)"}
    SQ -->|yes| SQR["Resolve series via seriesMap\nextract user tag, check match"]
    SQ -->|no| RQ{"Radarr QUEUE\nmatch (title)"}
    RQ -->|yes| RQR["Resolve movie via moviesMap\nextract user tag, check match"]
    RQ -->|no| SH{"Sonarr HISTORY\nmatch (title)"}
    SH -->|yes| SHR["Resolve series via seriesId\nextract user tag, check match"]
    SH -->|no| RH{"Radarr HISTORY\nmatch (title)"}
    RH -->|yes| RHR["Resolve movie via movieId\nextract user tag, check match"]
    RH -->|no| Skip(["Skip - unmatched"])

    SQR & RQR & SHR & RHR --> Tagged{"Tag matches\nrequesting user?"}
    Tagged -->|yes| Include(["Include in response"])
    Tagged -->|no| Skip

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
episodes {season, episode, title}[] (Series only) Episodes covered by this download, sorted by season/episode. Single-episode downloads have one entry; series packs have multiple. Empty array if Sonarr has no episode data.
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. Rate-limited to 10 failed attempts per IP per 15 minutes.

Request Body:

{ "username": "string", "password": "string", "rememberMe": false }
Field Required Description
username Yes Max 128 chars, must be a non-empty string
password Yes Max 256 chars
rememberMe No true = 30-day persistent cookie; false/omitted = session cookie

Response (200):

{
  "success": true,
  "user": { "id": "string", "name": "string", "isAdmin": false },
  "csrfToken": "64-char hex string"
}

Response (400): Invalid input (empty/overlong username or password).

Response (401):

{ "success": false, "error": "Invalid username or password" }

Response (429): Too many failed attempts from this IP.

Side Effects:

  • Sets emby_user cookie: httpOnly, sameSite: strict, secure in production, signed if COOKIE_SECRET is set. Payload: { id, name, isAdmin }. The Emby AccessToken is never included.
  • Sets csrf_token cookie: httpOnly: false (JS-readable), same security flags. 64-char hex.
  • Stores the Emby AccessToken server-side in tokenStore (used only for server-side Emby logout).

GET /api/auth/me

Check current session (no auth required — returns unauthenticated state rather than 401).

Response (authenticated):

{ "authenticated": true, "user": { "id": "string", "name": "string", "isAdmin": false } }

Response (not authenticated):

{ "authenticated": false }

GET /api/auth/csrf

Issue a fresh CSRF token. Used by the SPA after a page reload when the in-memory csrfToken variable is lost.

Response (200):

{ "csrfToken": "64-char hex string" }

Side Effect: Sets a new csrf_token cookie.


POST /api/auth/logout

Clear session and revoke the Emby token server-side. Does not require a CSRF token (auth routes are mounted before verifyCsrf; sameSite: strict provides equivalent protection).


GET /api/dashboard/stream

Server-Sent Events stream. Opens a persistent connection that pushes download data on every poll cycle.

Query Parameters:

Param Type Description
showAll "true" (Admin) Include all users' downloads

Response: Content-Type: text/event-stream

Each event is a data: frame containing JSON:

{
  "user": "Alice",
  "isAdmin": false,
  "downloads": [ /* download objects  same shape as /user-downloads */ ]
}

The connection is kept alive with : heartbeat comments every 25 seconds. The browser's EventSource reconnects automatically on failure.


GET /api/dashboard/user-downloads

Fetch downloads for the authenticated user (single HTTP request, no streaming).

Query Parameters:

Param Type Description
showAll "true" (Admin) Show all users' downloads

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", "type": "sse", "connectedAt": 1715817600000, "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 }
]

GET /api/history/recent

Returns recently completed (imported or failed) downloads from Sonarr/Radarr history for the authenticated user, filtered to the last days days.

Query Parameters:

Param Type Default Description
days integer RECENT_COMPLETED_DAYS env (default 7) How many days back to search. Capped at 90.
showAll "true" (Admin) Return records for all tagged users, not just the current user

Response (200):

{
  "user": "Alice",
  "isAdmin": false,
  "days": 7,
  "history": [
    {
      "type": "series",
      "outcome": "imported",
      "title": "Show.S01E01.720p",
      "seriesName": "My Show",
      "episodes": [
        { "season": 1, "episode": 1, "title": "Pilot" }
      ],
      "coverArt": "https://…/poster.jpg",
      "completedAt": "2026-05-15T18:00:00.000Z",
      "quality": "720p",
      "instanceName": "Main Sonarr",
      "arrLink": "https://sonarr.example.com/series/my-show",
      "allTags": ["alice"],
      "matchedUserTag": "alice",
      "arrRecordId": 1234,
      "failureMessage": null
    }
  ]
}
  • outcome is "imported" or "failed". Records with other event types (e.g. grabbed) are filtered out.
  • episodes is a sorted array of { season, episode, title } objects. Single-episode downloads have one entry; series packs have multiple. title is null if not returned by Sonarr. Empty array if Sonarr has no episode data.
  • failureMessage is only included when the authenticated user is an admin and outcome is "failed".
  • arrRecordId is only included for admin users.
  • Results are sorted newest first.
  • History data is cached server-side for 5 minutes (history:sonarr / history:radarr cache keys).

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

stateDiagram-v2
    [*] --> SplashScreen : Page load
    SplashScreen --> LoginForm : No session
    SplashScreen --> Dashboard : Valid session
    LoginForm --> Dashboard : Auth success
    Dashboard --> LoginForm : Logout

    state Dashboard {
        [*] --> ActiveDownloads
        ActiveDownloads --> ActiveDownloads : SSE update

        state StatusPanel {
            [*] --> Closed
            Closed --> Open : Click Status (admin)
            Open --> Closed : Click close
            Open --> Open : 5s refresh
        }
    }

Key Frontend Functions

Function Purpose
checkAuthentication() On load: check session → show dashboard or login
handleLogin() Authenticate, fade login → splash → dashboard
startSSE() Open EventSource to /stream; handles incoming data + first-message loading hide
stopSSE() Close EventSource and cancel reconnect timer
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, SSE clients, cache)
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.

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)

Live Push via SSE

The dashboard receives updates via a persistent EventSource connection to GET /api/dashboard/stream. The server pushes a new data: event immediately after every poll cycle completes — there is no client-side timer. The browser's EventSource implementation handles reconnection automatically on network interruption.

The status panel refreshes on a fixed 5-second timer and shows each SSE client with its connect duration.


11. Configuration

Environment Variables

Core

Variable Required Default Description
PORT No 3001 Server listen port
NODE_ENV No Set to production for production logging and startup validation. Does not control cookie secure flag or CSP upgrade-insecure-requests (both gated on TRUST_PROXY).
DATA_DIR No ./data Directory for tokens.json and server.log. Must be writable by the server process. In Docker: /app/data (named volume).
COOKIE_SECRET No If set, signs all session cookies with HMAC-SHA256. Strongly recommended in production.
TRUST_PROXY No Express trust proxy setting. Set to 1 (or a hop count) when behind a reverse proxy (nginx, Traefik, etc.) so req.ip and req.secure are correct.
TLS_ENABLED No true Set to false to disable HTTPS and run plain HTTP (e.g. when TLS is terminated by a reverse proxy).
TLS_CERT No certs/snakeoil.crt Path to the TLS certificate file (PEM). Defaults to the bundled self-signed snakeoil certificate.
TLS_KEY No certs/snakeoil.key Path to the TLS private key file (PEM). Defaults to the bundled snakeoil key.

Emby

Variable Required Default Description
EMBY_URL Yes Emby/Jellyfin base URL (e.g. https://emby.example.com)
EMBY_API_KEY Yes Emby API key (used by the poller to list users for tag badge classification)

Service Instances

Variable Required Default Description
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

* Either *_INSTANCES (JSON array) or legacy *_URL + *_API_KEY format is required.

Tuning

Variable Required Default Description
POLL_INTERVAL No 5000 Poll interval in ms. Set to 0, off, or false to disable background polling (on-demand mode).
RECENT_COMPLETED_DAYS No 7 Default lookback window (days) for GET /api/history/recent. Overridable per-request via ?days=. Max 90.
LOG_LEVEL No info debug, info, warn, error, silent

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 image

The production image uses a two-stage build on node:22-alpine:

  1. deps stage — runs npm ci --omit=dev to install only production dependencies.
  2. runtime stage — copies node_modules, server/, public/, and package.json. Runs as the built-in non-root node user (UID 1000). /app/data is owned by node for writable token store and logs.

Key environment variables set in the image:

  • NODE_ENV=production — enables production startup validation and logging
  • DATA_DIR=/app/data — token store and log file location
  • TLS is enabled by default using the bundled snakeoil self-signed certificate (certs/snakeoil.crt). Set TLS_CERT/TLS_KEY to your own certificate, or set TLS_ENABLED=false when terminating TLS at a reverse proxy.

Docker Compose

services:
  sofarr:
    image: docker.i3omb.com/sofarr:latest
    container_name: sofarr
    restart: unless-stopped
    ports:
      - "3001:3001"             # HTTPS by default (snakeoil cert if no TLS_CERT set)
    environment:
      - NODE_ENV=production
      - DATA_DIR=/app/data
      - COOKIE_SECRET=change-me-to-a-long-random-string
      # Option A: direct TLS (default). Supply your own cert/key:
      # - TLS_CERT=/app/certs/server.crt
      # - TLS_KEY=/app/certs/server.key
      # Option B: behind a TLS-terminating reverse proxy:
      # - TLS_ENABLED=false
      # - TRUST_PROXY=1
      - 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
    volumes:
      - sofarr-data:/app/data
      # Uncomment to supply your own certificate (Option A):
      # - /path/to/server.crt:/app/certs/server.crt:ro
      # - /path/to/server.key:/app/certs/server.key:ro

volumes:
  sofarr-data:

Security hardening checklist

  • Use HTTPS — TLS is on by default (snakeoil cert). Supply TLS_CERT/TLS_KEY pointing to a CA-signed certificate for trusted HTTPS. Alternatively terminate TLS at a reverse proxy and set TLS_ENABLED=false + TRUST_PROXY=1.
  • Set COOKIE_SECRET — enables HMAC-signed cookies, preventing client-side forgery.
  • Set TRUST_PROXY=1 only when a TLS-terminating reverse proxy sits in front — ensures req.secure is correct and the CSP upgrade-insecure-requests + secure cookie flag fire correctly.
  • Mount a named volume for DATA_DIR — token store and log file survive container recreates.
  • Rate limiting — the login endpoint is limited to 10 failed attempts per IP per 15 minutes; all API endpoints share a 300 req/15 min window. Set TRUST_PROXY correctly so the client IP is not the proxy's IP.

CI / CD

The .gitea/workflows/ directory contains three pipeline definitions:

File Trigger Purpose
ci.yml Every push / PR Security audit (npm audit --audit-level=high) + tests with V8 coverage
build-image.yml Push to main / develop Build and push Docker image to docker.i3omb.com
create-release.yml Tag push (v*) Create a Gitea release

Diagrams are written in Mermaid and render natively in Gitea — no CI workflow required. See Section 13.


13. Diagrams

All diagrams are written in Mermaid and render natively in Gitea and GitHub markdown.

13.1 Component Diagram

graph TB
    subgraph Browser
        html[index.html]
        appjs[app.js]
        css[style.css]
        html -->|loads| appjs
        html -->|loads| css
    end

    subgraph Express Server
        entry[index.js\nEntry Point]
        appfactory[app.js\ncreateApp factory]

        subgraph Middleware
            hm[helmet\nCSP nonce + HSTS]
            rl[express-rate-limit\nAPI + login]
            cp[cookie-parser\nsigned cookies]
            ej[express.json\n64kb limit]
            es[express.static]
            requireauth[requireAuth.js]
            verifycsrf[verifyCsrf.js\ndouble-submit]
        end

        subgraph Routes
            auth[auth.js\n/api/auth\npre-CSRF]
            dashboard[dashboard.js\n/api/dashboard\n+SSE /stream]
            emby_r[emby.js\n/api/emby]
            sab_r[sabnzbd.js\n/api/sabnzbd]
            sonarr_r[sonarr.js\n/api/sonarr]
            radarr_r[radarr.js\n/api/radarr]
            history_r[history.js\n/api/history]
        end

        subgraph Utilities
            poller[poller.js]
            cache[cache.js\nMemoryCache]
            config[config.js]
            qbt[qbittorrent.js\nQBittorrentClient]
            tokenstore[tokenStore.js\ntokens.json]
            sanitize[sanitizeError.js]
            logger[logger.js]
            historyfetcher[historyFetcher.js]
        end

        entry --> appfactory
        entry --> es
        entry --> poller

        appfactory --> hm & rl & cp & ej
        appfactory -->|pre-CSRF| auth
        appfactory --> verifycsrf
        appfactory --> dashboard & emby_r & sab_r & sonarr_r & radarr_r & history_r

        dashboard & emby_r & sab_r & sonarr_r & radarr_r & history_r --> requireauth
        auth --> tokenstore
        dashboard --> cache & poller & config & qbt
        history_r --> cache & config & historyfetcher
        historyfetcher --> cache & config
        poller --> cache & config & qbt & logger
        qbt --> config & logger
        auth & dashboard -.-> sanitize
    end

    subgraph External Services
        emby[Emby / Jellyfin]
        sab[SABnzbd]
        sonarr[Sonarr]
        radarr[Radarr]
        qbit[qBittorrent]
    end

    auth --> emby
    dashboard --> emby
    poller --> sab & sonarr & radarr
    qbt --> qbit
    emby_r --> emby
    sab_r --> sab
    sonarr_r --> sonarr
    radarr_r --> radarr

    appjs -->|POST /login, GET /me, GET /csrf, POST /logout| auth
    appjs -->|GET /stream SSE, GET /user-downloads, GET /status| dashboard
    es -->|serve static| html

13.2 Authentication Sequence

sequenceDiagram
    actor User
    participant Browser as Browser (app.js)
    participant Auth as Express /api/auth
    participant Tokens as TokenStore (tokens.json)
    participant Emby as Emby Server

    rect rgb(240,240,255)
        Note over Browser,Auth: Page Load
        Browser->>Auth: GET /api/auth/me
        Auth->>Auth: Read emby_user cookie (signed if COOKIE_SECRET)
        alt Cookie valid
            Auth-->>Browser: { authenticated: true, user }
            Browser->>Auth: GET /api/auth/csrf
            Auth-->>Browser: { csrfToken } + Set csrf_token cookie
            Browser->>Browser: store csrfToken in memory
            Browser->>Browser: showDashboard() + startSSE()
        else No cookie / tampered
            Auth-->>Browser: { authenticated: false }
            Browser->>Browser: showLogin()
        end
    end

    rect rgb(240,255,240)
        Note over Browser,Emby: Login
        User->>Browser: Enter credentials (+ rememberMe)
        Browser->>Auth: POST /api/auth/login
        Note right of Auth: Rate limit: max 10 failed attempts per IP / 15 min
        Auth->>Emby: POST /Users/authenticatebyname (DeviceId = sha256(username)[0:16])
        alt Valid credentials
            Emby-->>Auth: { User.Id, AccessToken }
            Auth->>Emby: GET /Users/{id}
            Emby-->>Auth: { Name, Policy.IsAdministrator }
            Auth->>Tokens: storeToken(userId, AccessToken)
            Note right of Tokens: Server-side only, 31-day TTL, atomic write
            Auth->>Auth: Set emby_user cookie (httpOnly, sameSite=strict, secure if TRUST_PROXY)
            Auth->>Auth: Set csrf_token cookie (httpOnly=false, sameSite=strict)
            Auth-->>Browser: { success: true, user, csrfToken }
            Browser->>Browser: showDashboard() + startSSE()
        else Invalid credentials
            Emby-->>Auth: 401
            Auth-->>Browser: { success: false, error }
        end
    end

    rect rgb(255,245,230)
        Note over Browser,Auth: Logout
        User->>Browser: Click Logout
        Browser->>Browser: stopSSE()
        Browser->>Auth: POST /api/auth/logout
        Auth->>Tokens: getToken(userId)
        Tokens-->>Auth: { accessToken }
        Auth->>Emby: POST /Sessions/Logout
        Auth->>Tokens: clearToken(userId)
        Auth->>Auth: clearCookie(emby_user, csrf_token)
        Auth-->>Browser: { success: true }
        Browser->>Browser: showLogin()
    end

13.3 Dashboard SSE Stream Sequence

sequenceDiagram
    actor User
    participant Browser as Browser (app.js)
    participant Dashboard as Express /api/dashboard
    participant Cache as MemoryCache
    participant Poller
    participant Ext as External Services

    User->>Browser: Login success / valid session
    Browser->>Dashboard: GET /api/dashboard/stream (EventSource)
    Dashboard->>Dashboard: requireAuth: extract user/isAdmin
    Dashboard->>Dashboard: Set Content-Type: text/event-stream, register in activeClients

    opt Polling disabled AND cache empty
        Dashboard->>Poller: pollAllServices()
        Poller->>Ext: Parallel API calls
        Ext-->>Poller: Raw data
        Poller->>Cache: set poll:* keys (TTL=30s)
    end

    Dashboard->>Cache: get all poll:* keys
    Dashboard->>Dashboard: Build maps, match downloads, extractUserTag / buildTagBadges
    Dashboard-->>Browser: data: { user, isAdmin, downloads }
    Browser->>Browser: hideLoading() + renderDownloads()

    loop Every poll cycle
        Poller->>Poller: pollAllServices() complete
        Poller->>Dashboard: onPollComplete callback fires
        Dashboard->>Cache: get all poll:* keys
        Dashboard->>Dashboard: Rebuild payload
        Dashboard-->>Browser: data: { user, isAdmin, downloads }
        Browser->>Browser: renderDownloads() diff-based
    end

    Note over Dashboard,Browser: : heartbeat every 25s keeps connection alive

    User->>Browser: Close tab / logout
    Browser->>Dashboard: TCP close (req close event)
    Dashboard->>Dashboard: offPollComplete(cb), clearInterval(heartbeat), delete activeClients[key]

13.4 Background Polling Cycle

sequenceDiagram
    participant Entry as index.js (startup)
    participant Poller
    participant Config
    participant SAB as SABnzbd (per instance)
    participant Sonarr as Sonarr (per instance)
    participant Radarr as Radarr (per instance)
    participant QBT as qBittorrent Client
    participant Cache as MemoryCache

    Entry->>Poller: startPoller()
    alt POLL_INTERVAL > 0
        Poller->>Poller: pollAllServices() immediate
        Poller->>Poller: setInterval(pollAllServices, POLL_INTERVAL)
    else POLL_INTERVAL = 0
        Poller-->>Entry: on-demand mode
    end

    Note over Poller: Each poll cycle
    Poller->>Poller: polling flag check (skip if concurrent)
    Poller->>Poller: polling = true

    Poller->>Config: getSABnzbdInstances() / getSonarrInstances() / getRadarrInstances()
    Config-->>Poller: instance configs

    Note over Poller,Cache: All 9 fetches run in parallel via Promise.all, each wrapped in timed()

    Poller->>SAB: GET /api?mode=queue
    SAB-->>Poller: { queue: { slots, status, speed } }
    Poller->>SAB: GET /api?mode=history&limit=10
    SAB-->>Poller: { history: { slots } }
    Poller->>Sonarr: GET /api/v3/tag + queue + history
    Sonarr-->>Poller: tags, queue records (includeSeries), history
    Poller->>Radarr: GET /api/v3/tag + queue + history
    Radarr-->>Poller: tags, queue records (includeMovie), history
    Poller->>QBT: getTorrents()
    QBT-->>Poller: [{ name, progress, ... }]

    Poller->>Poller: Record per-task timings: lastPollTimings = { totalMs, timestamp, tasks }
    Poller->>Cache: set poll:* keys (TTL = POLL_INTERVAL x 3)
    Poller->>Poller: Notify SSE subscribers (forEach cb())
    Poller->>Poller: polling = false

13.5 Server Class Diagram

classDiagram
    class EntryPoint["index.js (EntryPoint)"] {
        +startPoller()
        +app.listen()
        setupLogging()
        serveStatic()
    }
    class createApp["app.js (createApp factory)"] {
        +createApp(skipRateLimits?) Express
        mountHelmet()
        mountRateLimiters()
        mountRoutes()
        mountErrorHandler()
    }
    class AuthRouter["auth.js (Router)"] {
        +POST /login
        +GET /me
        +GET /csrf
        +POST /logout
        authenticateViaEmby()
        issueCookies()
        revokeToken()
    }
    class DashboardRouter["dashboard.js (Router)"] {
        -activeClients Map
        +GET /stream SSE
        +GET /user-downloads
        +GET /user-summary
        +GET /status
        +GET /cover-art
        buildDownloadPayload()
        extractUserTag()
        buildTagBadges()
        getEmbyUsers()
    }
    class RequireAuth["requireAuth.js (Middleware)"] {
        +requireAuth(req, res, next)
        readCookie()
        validateSchema()
    }
    class VerifyCsrf["verifyCsrf.js (Middleware)"] {
        +verifyCsrf(req, res, next)
        timingSafeEqual()
    }
    class MemoryCache {
        -store Map
        +get(key) any
        +set(key, value, ttlMs)
        +invalidate(key)
        +clear()
        +getStats() CacheStats
    }
    class Poller {
        -POLL_INTERVAL number
        -polling boolean
        -subscribers Set
        +startPoller()
        +stopPoller()
        +pollAllServices()
        +onPollComplete(cb)
        +offPollComplete(cb)
        +getLastPollTimings() PollTimings
    }
    class Config {
        +getSABnzbdInstances() Instance[]
        +getSonarrInstances() Instance[]
        +getRadarrInstances() Instance[]
        +getQbittorrentInstances() Instance[]
    }
    class QBittorrentClient {
        -url string
        -authCookie string
        +login() bool
        +getTorrents() Torrent[]
        +makeRequest(endpoint)
    }
    class TokenStore {
        -STORE_PATH string
        -TOKEN_TTL_MS 31days
        +storeToken(userId, token)
        +getToken(userId)
        +clearToken(userId)
        atomicWrite()
        pruneExpired()
    }
    class SanitizeError {
        +sanitizeError(err) string
        redactQueryParams()
        redactAuthHeaders()
    }

    EntryPoint --> createApp : createApp()
    EntryPoint --> Poller : startPoller()
    createApp --> AuthRouter : mount pre-CSRF
    createApp --> VerifyCsrf : apply to /api
    createApp --> DashboardRouter
    DashboardRouter --> RequireAuth
    DashboardRouter --> MemoryCache
    DashboardRouter --> Poller
    DashboardRouter --> Config
    DashboardRouter ..> SanitizeError
    AuthRouter --> TokenStore
    AuthRouter ..> SanitizeError
    Poller --> MemoryCache
    Poller --> Config
    Poller --> QBittorrentClient
    QBittorrentClient --> Config

13.6 Data Model Diagram

classDiagram
    class Download {
        +type series|movie|torrent
        +title string
        +coverArt string
        +status string
        +progress string
        +size string
        +mb string
        +mbmissing string
        +speed string
        +eta string
        +seriesName string
        +movieName string
        +allTags string[]
        +matchedUserTag string
        +tagBadges TagBadge[]
        +importIssues string[]
        +downloadPath string
        +targetPath string
        +arrLink string
        +seeds number
        +peers number
        +availability string
        +hash string
        +completedAt string
    }
    class TagBadge {
        +label string
        +matchedUser string
    }
    class APIResponse {
        +user string
        +isAdmin boolean
        +downloads Download[]
    }
    class SSEEvent {
        +user string
        +isAdmin boolean
        +downloads Download[]
    }
    class StatusResponse {
        +server ServerInfo
        +polling PollingInfo
        +cache CacheStats
        +clients ClientInfo[]
    }
    class SessionCookie {
        +id string
        +name string
        +isAdmin boolean
    }
    class SABnzbdQueueSlot {
        +filename string
        +percentage string
        +mb string
        +mbmissing string
        +timeleft string
        +status string
    }
    class qBittorrentTorrent {
        +name string
        +hash string
        +progress float
        +state string
        +dlspeed number
        +eta number
        +num_seeds number
        +num_leechs number
        +availability number
    }
    class SonarrQueueRecord {
        +seriesId number
        +series SonarrSeries
        +title string
        +trackedDownloadStatus string
        +statusMessages StatusMessage[]
    }
    class RadarrQueueRecord {
        +movieId number
        +movie RadarrMovie
        +title string
        +trackedDownloadStatus string
        +statusMessages StatusMessage[]
    }

    APIResponse "1" *-- "many" Download
    SSEEvent "1" *-- "many" Download
    Download "1" *-- "many" TagBadge
    SABnzbdQueueSlot ..> Download : matched and transformed
    qBittorrentTorrent ..> Download : mapTorrentToDownload()
    SonarrQueueRecord ..> Download : coverArt, seriesName, tags
    RadarrQueueRecord ..> Download : coverArt, movieName, tags

13.7 Frontend UI State Diagram

stateDiagram-v2
    [*] --> SplashScreen : Page load

    SplashScreen --> CheckAuth : checkAuthentication()

    state CheckAuth <<choice>>
    CheckAuth --> LoginForm : No session
    CheckAuth --> Dashboard : Valid session

    state LoginForm {
        [*] --> Idle
        Idle --> Submitting : Submit form
        Submitting --> Error : Auth failed
        Error --> Submitting : Re-submit
        Submitting --> [*] : Auth success
    }

    LoginForm --> Dashboard : Auth success (fade transition)

    state Dashboard {
        [*] --> Rendering
        Rendering --> Rendering : SSE message triggers renderDownloads()
        Rendering --> Rendering : Theme change

        state SSEConnection {
            [*] --> Connecting
            Connecting --> Connected : First message
            Connected --> Reconnecting : Connection lost
            Reconnecting --> Connected : Auto-reconnect
            Connected --> Connecting : showAll toggled
        }

        state StatusPanel {
            [*] --> Closed
            Closed --> Open : Click Status (admin)
            Open --> Closed : Click close
            Open --> Open : 5s timer refresh
        }
    }

    Dashboard --> LoginForm : Logout (stopSSE)

13.8 Poller State Diagram

stateDiagram-v2
    [*] --> CheckConfig : startPoller()

    state CheckConfig <<choice>>
    CheckConfig --> Disabled : POLL_INTERVAL = 0
    CheckConfig --> Idle : POLL_INTERVAL > 0

    state Disabled {
        [*] --> OnDemand
        OnDemand : No background timer. Data fetched when dashboard request finds empty cache.
    }

    Disabled --> Polling : dashboard triggers pollAllServices()
    Polling --> Disabled : Poll complete (on-demand)

    Idle --> Polling : setInterval fires or immediate first poll

    state Polling {
        [*] --> Locked
        Locked : polling = true
        Locked --> Fetching
        Fetching --> Storing : All promises resolved
        Fetching --> HandleError : Per-service error (caught)
        Storing --> Notifying : Cache updated, TTL = POLL_INTERVAL x 3
        Notifying : Notify SSE subscribers
        Notifying --> Done
        Done : polling = false
        Done --> [*]
    }

    state HandleError {
        [*] --> LogError
        LogError : Log error, polling = false
    }

    Polling --> Idle : Poll complete
    HandleError --> Idle : Next interval

    state ConcurrentSkip {
        [*] --> Skip
        Skip : polling === true, skip cycle
    }
    Idle --> ConcurrentSkip : Interval fires while previous still running
    ConcurrentSkip --> Idle : Log skip

13.9 Download Matching Flow

flowchart TD
    A([Start: user request]) --> B[Read all poll:* keys from MemoryCache]
    B --> C[Build seriesMap, moviesMap\nsonarrTagMap, radarrTagMap]
    C --> D{showAll?}
    D -->|yes| E[Fetch Emby user list\ncached 60s → embyUserMap]
    D -->|no| F
    E --> F[userDownloads = empty array]

    F --> G[/SABnzbd queue slots/]
    G --> H{Matches Sonarr queue?}
    H -->|yes| I[Resolve series\nextractAllTags + extractUserTag]
    I --> J{showAll + anyTag\nor matchedUserTag?}
    J -->|yes| K[Build Download object\nAdd tagBadges if showAll\nAdd importIssues, admin fields]
    K --> L[Push to userDownloads]
    H --> M{Matches Radarr queue?}
    M -->|yes| N[Resolve movie\nextractAllTags + extractUserTag]
    N --> J

    L --> G

    G --> O[/SABnzbd history slots/]
    O --> P{Matches Sonarr history?}
    P -->|yes| Q[Resolve series\nBuild Download type=series\nAdd completedAt]
    Q --> R{showAll+anyTag\nor matchedUserTag?}
    R -->|yes| S[Push to userDownloads]
    P --> T{Matches Radarr history?}
    T -->|yes| U[Resolve movie\nBuild Download type=movie\nAdd completedAt]
    U --> R

    S --> O

    O --> V[/qBittorrent torrents/]
    V --> W{Matches Sonarr queue?}
    W -->|yes| X[mapTorrentToDownload\n+ enrich with series]
    X --> Y{Tag matches?}
    Y -->|yes| Z[Push to userDownloads]
    W --> AA{Matches Radarr queue?}
    AA -->|yes| AB[mapTorrentToDownload\n+ enrich with movie]
    AB --> Y
    AA --> AC{Matches Sonarr history?}
    AC -->|yes| AD[Resolve series via seriesMap]
    AD --> Y
    AC --> AE{Matches Radarr history?}
    AE -->|yes| AF[Resolve movie via moviesMap]
    AF --> Y
    AE -->|no| AG[Skip - unmatched torrent]

    Z --> V
    AG --> V

    V --> AH([Return JSON\nuser, isAdmin, downloads])

    style K fill:#d4edda
    style Q fill:#d4edda
    style U fill:#d4edda
    style X fill:#d4edda
    style AB fill:#d4edda
    style AD fill:#d4edda
    style AF fill:#d4edda
    style AG fill:#f8d7da