Compare commits

..

1 Commits

Author SHA1 Message Date
Gandalf feb2d26c5b Merge pull request 'ci: build multi-arch images (amd64, arm64, arm/v7)' (#3) from develop into release/0.1
Build and Push Docker Image / build (push) Has been cancelled
Reviewed-on: #3
2026-05-15 20:57:45 +01:00
87 changed files with 2041 additions and 17703 deletions
-9
View File
@@ -6,19 +6,10 @@ node_modules/
.gitignore
.DS_Store
*.log
**/*.log
client/
dist/
build/
coverage/
tests/
vitest.config.js
.markdownlint.json
README.md
CHANGELOG.md
SECURITY.md
LICENSE
.dockerignore
Dockerfile
.gitea/
docs/
+22
View File
@@ -0,0 +1,22 @@
# Server Configuration
PORT=3001
# Emby Configuration (single instance)
EMBY_URL=http://localhost:8096
EMBY_API_KEY=your_emby_api_key
# SABnzbd Instances (JSON array)
# Format: [{"name": "Instance Name", "url": "http://...", "apiKey": "..."}]
SABNZBD_INSTANCES=[{"name": "Primary", "url": "http://localhost:8080", "apiKey": "your_api_key"}]
# Sonarr Instances (JSON array)
SONARR_INSTANCES=[{"name": "Primary", "url": "http://localhost:8989", "apiKey": "your_api_key"}]
# Radarr Instances (JSON array)
RADARR_INSTANCES=[{"name": "Primary", "url": "http://localhost:7878", "apiKey": "your_api_key"}]
# qBittorrent Instances (JSON array)
QBITTORRENT_INSTANCES=[
{"name": "ransackedcrew", "url": "https://qbittorrent.ransackedcrew.info", "username": "gronod", "password": "K32D&JDjtHA&mC"},
{"name": "i3omb", "url": "https://qbittorrent.i3omb.com", "username": "admin", "password": "b053288369XX!"}
]
-97
View File
@@ -14,86 +14,6 @@ PORT=3001
# - silent: No logging
LOG_LEVEL=info
# Cookie signing secret for tamper-proof session cookies
# Required in production (server exits on startup if unset).
# Generate with: openssl rand -hex 32
COOKIE_SECRET=your-cookie-secret-here
# =============================================================================
# WEBHOOK SETTINGS
# =============================================================================
# Secret for validating incoming webhooks from Sonarr and Radarr
# Required for webhook endpoints to accept requests
# Sonarr/Radarr must send this secret in the X-Sofarr-Webhook-Secret header
# Generate with: openssl rand -hex 32
SOFARR_WEBHOOK_SECRET=your-webhook-secret-here
# Public base URL of Sofarr (for webhook configuration)
# Required for the one-click webhook setup endpoints
# Sonarr/Radarr need this URL to know where to send webhook events
# Example: https://sofarr.example.com or https://192.168.1.100:3001
SOFARR_BASE_URL=https://your-sofarr-url
# --- Webhook Polling Optimization (Phase 5) ---
# Minutes of silence after which the poller falls back to a full poll
# even if webhooks were recently active. Default: 10 minutes.
# Set lower (e.g. 2) for more aggressive fallback; higher (e.g. 30) to
# reduce background polling on very stable setups.
# WEBHOOK_FALLBACK_TIMEOUT=10
# When an instance has received a recent webhook event, the poller skips
# its queue/history fetch entirely (saving API calls). If you still want
# a periodic poll even with webhooks, set this to 1 to disable skipping.
# Default behaviour: skip polling for instances with recent webhook activity.
# WEBHOOK_POLL_INTERVAL_MULTIPLIER=3
# =============================================================================
# TLS / HTTPS
# =============================================================================
# TLS is enabled by default using the bundled snakeoil self-signed certificate
# (valid for localhost/127.0.0.1, 10-year expiry).
# Set TLS_CERT and TLS_KEY to use your own certificate (recommended).
# Set TLS_ENABLED=false to run in plain HTTP mode (e.g. behind a TLS proxy).
#
# To generate a self-signed cert for your own hostname:
# openssl req -x509 -newkey rsa:2048 -keyout server.key -out server.crt \
# -days 365 -nodes -subj "/CN=yourhostname" \
# -addext "subjectAltName=DNS:yourhostname,IP:192.168.x.x"
#
# TLS_ENABLED=true
# TLS_CERT=/path/to/server.crt
# TLS_KEY=/path/to/server.key
# =============================================================================
# REVERSE PROXY & DEPLOYMENT
# =============================================================================
# Set to 1 when running behind a reverse proxy (Nginx, Caddy, Traefik).
# This makes Express trust X-Forwarded-For and X-Forwarded-Proto so that
# req.ip reflects the real client IP and cookies are marked secure correctly.
# Leave unset if sofarr is exposed directly to the internet.
# TRUST_PROXY=1
# Directory for persistent data (SQLite token store, server logs).
# Must be writable by the process user (UID 1000 in the container).
# Defaults to ./data relative to the project root.
# DATA_DIR=/app/data
# Number of days of completed download history to show in the Recently Completed section.
# Override per-request with ?days=N (capped at 90).
# RECENT_COMPLETED_DAYS=7
# Background polling interval in milliseconds (default: 5000)
# sofarr polls all services in the background and caches results so
# dashboard requests are near-instant.
# Set to 0, "off", "false", or "disabled" to disable background polling.
# When disabled, data is fetched on-demand when a user opens the dashboard
# and cached for 30 seconds so other users benefit from the same fetch.
# POLL_INTERVAL=5000
# =============================================================================
# EMBY (Authentication - Required)
# =============================================================================
@@ -124,17 +44,6 @@ QBITTORRENT_INSTANCES=[{"name":"main","url":"https://qbittorrent.example.com","u
# QBITTORRENT_USERNAME=admin
# QBITTORRENT_PASSWORD=your-password
# =============================================================================
# RTORRENT_INSTANCES (JSON Array)
# The url MUST include the full XML-RPC endpoint path.
# Standard/self-hosted installs: .../RPC2
# whatbox.ca users: .../xmlrpc
# Other installations may use different custom paths.
# Example:
RTORRENT_INSTANCES=[{"name":"main","url":"http://rtorrent.local:8080/RPC2","username":"rtorrent","password":"rtorrent"}]
# For whatbox.ca:
# RTORRENT_INSTANCES=[{"name":"whatbox","url":"https://user.whatbox.ca/xmlrpc","username":"user","password":"pass"}]
# =============================================================================
# SONARR INSTANCES (JSON Array Format)
# Add one or more Sonarr instances as a single-line JSON array
@@ -165,10 +74,4 @@ RADARR_INSTANCES=[{"name":"main","url":"https://radarr.example.com","apiKey":"yo
# 3. URLs should include protocol (http:// or https://)
# 4. For qBittorrent, ensure Web UI is enabled in settings
# 5. User downloads are matched by tags in Sonarr/Radarr - tag your media!
# 6. Background polling keeps data fresh; disable it for low-resource setups
# 7. Webhooks (SOFARR_WEBHOOK_SECRET + SOFARR_BASE_URL) enable real-time
# push updates from Sonarr/Radarr and automatically reduce polling load.
# Use the Webhooks Configuration panel in the dashboard UI to enable them
# with one click. The secret must match the header value in each *arr
# notification connection (X-Sofarr-Webhook-Secret).
# =============================================================================
+16 -18
View File
@@ -4,7 +4,6 @@ on:
push:
branches:
- 'release/**'
- 'develop*'
jobs:
build:
@@ -13,33 +12,32 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Compute image tags
id: meta
- name: Extract version from package.json
id: version
run: |
VERSION=$(node -p "require('./package.json').version")
BRANCH=${GITHUB_REF#refs/heads/}
RELEASE_NAME=${BRANCH#release/}
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "release=${RELEASE_NAME}" >> $GITHUB_OUTPUT
echo "Building version ${VERSION} from branch ${BRANCH}"
if [[ "$BRANCH" == develop* ]]; then
# Sanitise branch name for tag: replace slashes with dashes
SAFE_BRANCH=$(echo "$BRANCH" | tr '/' '-')
echo "tags=reg.i3omb.com/sofarr:${SAFE_BRANCH}" >> $GITHUB_OUTPUT
echo "Building develop image ${SAFE_BRANCH} (version ${VERSION})"
else
RELEASE_NAME=${BRANCH#release/}
TAGS="reg.i3omb.com/sofarr:${VERSION}"
TAGS="${TAGS},reg.i3omb.com/sofarr:${RELEASE_NAME}"
TAGS="${TAGS},reg.i3omb.com/sofarr:latest"
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
echo "Building release image ${VERSION} from branch ${BRANCH}"
fi
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
reg.i3omb.com/sofarr:${{ steps.version.outputs.version }}
reg.i3omb.com/sofarr:${{ steps.version.outputs.release }}
reg.i3omb.com/sofarr:latest
labels: |
org.opencontainers.image.version=${{ steps.meta.outputs.version }}
org.opencontainers.image.version=${{ steps.version.outputs.version }}
org.opencontainers.image.created=${{ github.event.head_commit.timestamp }}
-62
View File
@@ -1,62 +0,0 @@
name: CI
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
jobs:
audit:
name: Security audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run security audit (fail on high+)
run: npm audit --audit-level=high
- name: Check for critical vulnerabilities
run: npm audit --audit-level=critical --json | jq -e '.metadata.vulnerabilities.critical == 0' || (echo "Critical vulnerabilities found!" && exit 1)
continue-on-error: false
test:
name: Tests & coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run tests with coverage
run: npm run test:coverage
env:
# Required by tokenStore (writable temp dir in CI)
DATA_DIR: /tmp/sofarr-ci-data
# Disable rate limiters so integration tests don't hit 429s
SKIP_RATE_LIMIT: "1"
NODE_ENV: test
- name: Upload coverage report
uses: actions/upload-artifact@v3
if: always()
with:
name: coverage-report
path: coverage/
retention-days: 14
-108
View File
@@ -1,108 +0,0 @@
name: Docs Check
on:
push:
branches: ["**", "!main", "!release/**"]
paths:
- "**.md"
- ".gitea/workflows/docs-check.yml"
pull_request:
branches: ["**", "!main", "!release/**"]
paths:
- "**.md"
- ".gitea/workflows/docs-check.yml"
jobs:
markdown-lint:
name: Markdown lint
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install markdownlint-cli
run: npm install -g markdownlint-cli
- name: Lint all Markdown files
run: markdownlint "**/*.md" --ignore node_modules
mermaid-parse:
name: Mermaid diagram parse check
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install mermaid and jsdom
run: npm install mermaid jsdom
- name: Extract and validate Mermaid diagrams
run: |
cat > check-mermaid.cjs << 'SCRIPT'
const { JSDOM } = require('jsdom');
const fs = require('fs');
const path = require('path');
// Provide minimal browser globals so mermaid.parse() works in Node
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'http://localhost' });
globalThis.window = dom.window;
globalThis.document = dom.window.document;
globalThis.DOMPurify = {
addHook: () => {}, removeHook: () => {}, setConfig: () => {},
sanitize: (s) => s, isValidAttribute: () => true,
};
function findMdFiles(dir) {
const out = [];
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, e.name);
if (e.isDirectory() && e.name !== 'node_modules' && !e.name.startsWith('.'))
out.push(...findMdFiles(full));
else if (e.isFile() && e.name.endsWith('.md'))
out.push(full);
}
return out;
}
import('./node_modules/mermaid/dist/mermaid.core.mjs').then(async (m) => {
const mermaid = m.default;
let errors = 0, total = 0;
for (const mdFile of findMdFiles('.')) {
const content = fs.readFileSync(mdFile, 'utf8');
const blocks = [...content.matchAll(/^```mermaid\n([\s\S]*?)^```/gm)];
if (!blocks.length) continue;
console.log(`\nChecking ${mdFile} (${blocks.length} diagram(s))`);
for (let i = 0; i < blocks.length; i++) {
total++;
const diagram = blocks[i][1].trim();
try {
await mermaid.parse(diagram);
console.log(` [OK] diagram ${i + 1}`);
} catch (err) {
const msg = String(err.message || err).split('\n')[0];
console.error(` [FAIL] diagram ${i + 1}: ${msg}`);
console.log(`::warning file=${mdFile}::Mermaid diagram ${i + 1} failed: ${msg}`);
errors++;
}
}
}
console.log(`\nTotal: ${total}. Failures: ${errors}`);
if (errors > 0) {
console.log(`::warning::${errors} Mermaid diagram(s) failed to parse.`);
process.exit(1);
}
}).catch(e => { console.error('Fatal:', e.message); process.exit(1); });
SCRIPT
node check-mermaid.cjs
-83
View File
@@ -1,83 +0,0 @@
name: Licence Check
on:
push:
branches: ["**", "!main", "!release/**"]
paths:
- "package.json"
- "package-lock.json"
- ".gitea/workflows/licence-check.yml"
- "**/*.js"
- "**/*.ts"
- "**/*.jsx"
- "**/*.tsx"
pull_request:
branches: ["**", "!main", "!release/**"]
paths:
- "package.json"
- "package-lock.json"
- ".gitea/workflows/licence-check.yml"
- "**/*.js"
- "**/*.ts"
- "**/*.jsx"
- "**/*.tsx"
jobs:
licence-check:
name: Licence compatibility and copyright header verification
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install production dependencies
run: npm ci --omit=dev
- name: Check licence compatibility
run: |
npx --yes license-checker --production \
--onlyAllow "MIT;ISC;MIT-0;BSD-2-Clause;BSD-3-Clause;Apache-2.0;CC0-1.0;BlueOak-1.0.0" \
--excludePrivatePackages \
&& echo "All production dependency licences are compatible with MIT."
- name: Check copyright headers in source files
run: |
#!/bin/bash
set -e
# Find all source files, excluding build artifacts and node_modules
SOURCE_FILES=$(find . -type f \( -name "*.js" -o -name "*.ts" -o -name "*.jsx" -o -name "*.tsx" \) \
! -path "./node_modules/*" \
! -path "./.git/*" \
! -path "./dist/*" \
! -path "./build/*" \
! -path "./.gitea/*")
MISSING_HEADER=0
# Check each file for MIT-compliant copyright header
while IFS= read -r file; do
if [ -z "$file" ]; then
continue
fi
# Check if file starts with a copyright header containing: Copyright, year (4 digits), name, and MIT License
if ! head -n 5 "$file" | grep -qiE "Copyright.*[0-9]{4}.*MIT"; then
echo "❌ Missing MIT-compliant copyright header in: $file"
echo " Required format: // Copyright (c) YYYY Name. MIT License."
MISSING_HEADER=$((MISSING_HEADER + 1))
fi
done <<< "$SOURCE_FILES"
if [ $MISSING_HEADER -gt 0 ]; then
echo ""
echo "⚠️ Found $MISSING_HEADER file(s) with missing or non-compliant copyright headers."
exit 1
else
echo "✅ All source files have MIT-compliant copyright headers."
fi
-6
View File
@@ -1,12 +1,6 @@
node_modules/
coverage/
.env
dist/
build/
.DS_Store
*.log
**/*.log
data/
*.db
*.db-wal
*.db-shm
-18
View File
@@ -1,18 +0,0 @@
{
"default": true,
"MD009": false,
"MD012": false,
"MD013": false,
"MD022": false,
"MD024": false,
"MD029": false,
"MD031": false,
"MD032": false,
"MD033": false,
"MD034": false,
"MD036": false,
"MD040": false,
"MD041": false,
"MD058": false,
"MD060": false
}
-927
View File
@@ -1,927 +0,0 @@
# sofarr — Architecture
Comprehensive technical reference for the **sofarr** application: a personal media download dashboard that aggregates download activity from SABnzbd, Sonarr, Radarr, qBittorrent, Transmission, and rTorrent, filters results by Emby/Jellyfin user identity, and presents a real-time personalised dashboard.
---
## Table of Contents
1. [Introduction](#1-introduction)
2. [High-Level Architecture](#2-high-level-architecture)
3. [Pluggable Architecture Layers](#3-pluggable-architecture-layers)
4. [Webhook System](#4-webhook-system)
5. [Data Flow and Real-time Updates](#5-data-flow-and-real-time-updates)
6. [Caching and Smart Polling](#6-caching-and-smart-polling)
7. [Key Subsystems](#7-key-subsystems)
8. [Directory Structure](#8-directory-structure)
9. [Configuration and Environment Variables](#9-configuration-and-environment-variables)
10. [Security Model](#10-security-model)
11. [Technology Stack](#11-technology-stack)
---
## 1. Introduction
sofarr is a **Node.js/Express single-page application** that provides a personalised view of media downloads. It:
1. **Authenticates** users against an Emby/Jellyfin media server.
2. **Aggregates** download data from multiple *arr service instances and download clients.
3. **Filters** downloads per user — each user only sees media tagged with their username in Sonarr/Radarr.
4. **Presents** a real-time dashboard with progress, speeds, cover art, and status, updated either via background polling or instant webhook push from Sonarr/Radarr.
Admin users can view all users' downloads, see server status, cache statistics, poll timings, and perform blocklist-and-search operations.
Three pluggable layers form the architectural core:
| Layer | Name | Location |
|-------|------|----------|
| Download client abstraction | **PDCA** — Pluggable Download Client Architecture | `server/clients/` + `server/utils/downloadClients.js` |
| *arr data retrieval | **PALDRA** — Pluggable *Arr Library Data Retrieval Architecture | `server/utils/arrRetrievers.js` |
| Real-time push | **Webhook Receiver** | `server/routes/webhook.js` |
---
## 2. High-Level Architecture
```mermaid
flowchart TB
subgraph Browser["Browser (SPA — public/)"]
login["Login Form"]
dash["Dashboard Cards"]
status["Status Panel\n(Admin only)"]
history["History Tab"]
end
subgraph Server["Express Server (:3001)"]
direction TB
mw["Middleware\nHelmet · rate-limit · cookie-parser · verifyCsrf"]
auth_r["Auth Routes\n/api/auth"]
dash_r["Dashboard Routes\n/api/dashboard (SSE /stream)"]
wh_r["Webhook Routes\n/api/webhook/sonarr|radarr"]
hist_r["History Routes\n/api/history"]
proxy_r["Proxy Routes\n/api/sonarr · /api/radarr\n/api/sabnzbd · /api/emby"]
subgraph Core["Core Utilities"]
poller["Poller\n(smart background polling)"]
cache["MemoryCache\n(poll:* + webhook metrics)"]
pdca["PDCA Registry\n(download clients)"]
paldra["PALDRA Registry\n(arr retrievers)"]
tokenstore["TokenStore\n(tokens.json)"]
end
end
subgraph Ext["External Services"]
sab["SABnzbd"]
sonarr["Sonarr"]
radarr["Radarr"]
qbt["qBittorrent"]
rtorrent["rTorrent"]
transmission["Transmission"]
emby["Emby / Jellyfin"]
end
login -->|"POST /api/auth/login"| auth_r
dash -->|"GET /api/dashboard/stream (SSE)"| dash_r
status -->|"GET /api/dashboard/status"| dash_r
history -->|"GET /api/history/recent"| hist_r
auth_r --> tokenstore
auth_r -->|"authenticate"| emby
dash_r --> cache
dash_r --> poller
wh_r --> cache
wh_r --> paldra
hist_r --> cache
proxy_r -->|"proxy"| sonarr & radarr & sab & emby
poller --> pdca & paldra
poller --> cache
pdca -->|"HTTP/API"| sab & qbt & rtorrent & transmission
paldra -->|"HTTP/API"| sonarr & radarr
sonarr & radarr -->|"POST /api/webhook/*"| wh_r
```
### Request routing summary
```
Browser (SPA)
│ POST /api/auth/login → Auth routes → Emby verify → set httpOnly cookie
│ GET /api/dashboard/stream → SSE stream → cache → matched downloads
│ POST /api/webhook/* ← Sonarr/Radarr push events
Express Server (:3001)
├── Helmet (CSP nonce, HSTS, X-Frame-Options, …)
├── express-rate-limit (300/15 min general; 60/1 min webhook; 10 fails/15 min login)
├── cookie-parser (HMAC-signed session cookie)
├── verifyCsrf (double-submit cookie, all state-changing /api routes except auth + webhook)
├── /api/auth → login, logout, me, csrf
├── /api/webhook → [rate-limit] → [secret validation] → [payload validation]
│ → [replay check] → updateWebhookMetrics → processWebhookEvent
├── /api/dashboard → requireAuth → read cache → match downloads → SSE/JSON
├── /api/history → requireAuth → historyFetcher (5 min cache) → filter + dedup
├── /api/sonarr|radarr → requireAuth → verifyCsrf → proxy to *arr API
└── /api/sabnzbd|emby → requireAuth → verifyCsrf → proxy
Background:
Poller (setInterval POLL_INTERVAL ms)
└── shouldSkipInstancePolling? ──yes──► extend cache TTL, increment pollsSkipped
│ no (or fallback triggered)
PDCA Registry.getDownloadsByClientType()
PALDRA Registry.getQueuesByType() / getHistoryByType() / getTagsByType()
cache.set('poll:*', data, TTL)
notify pollSubscribers → SSE push to all connected browsers
```
---
## 3. Pluggable Architecture Layers
### 3.1 Pluggable Download Client Architecture (PDCA)
#### Overview
The PDCA provides a unified, extensible interface for all download clients. This abstraction layer enables:
- **Client-agnostic polling** — the poller contains no client-specific logic.
- **Easy extension** — add a new client by implementing one interface.
- **Consistent normalisation** — all clients return standardised download objects.
- **Centralised configuration** — a single registry manages all instances.
- **Error isolation** — individual client failures do not affect other clients.
#### Abstract Base Class
All download clients extend `DownloadClient` (`server/clients/DownloadClient.js`):
```javascript
class DownloadClient {
constructor(instanceConfig)
getClientType(): string
getInstanceId(): string
async testConnection(): Promise<boolean>
async getActiveDownloads(): Promise<NormalizedDownload[]>
async getClientStatus(): Promise<Object|null> // optional
normalizeDownload(download): NormalizedDownload
}
```
#### Client Implementations
```
DownloadClient (abstract)
├── SABnzbdClient — REST API, API key auth; handles queue + history; normalises time/size units
├── QBittorrentClient — Sync API (incremental deltas), cookie auth, fallback to /torrents/info
├── TransmissionClient — JSON-RPC, session-ID management
└── RTorrentClient — XML-RPC (xmlrpc 1.3.2), HTTP Basic Auth; maps rTorrent states to normalised statuses
```
#### Normalised Download Schema
Every client returns objects conforming to this schema:
```javascript
interface NormalizedDownload {
id: string // Client-specific unique ID
title: string // Download title/name
type: 'usenet' | 'torrent' // Download type
client: string // Client identifier ('sabnzbd', 'qbittorrent', etc.)
instanceId: string // Instance identifier
instanceName: string // Instance display name
status: string // Normalised status (Downloading, Seeding, etc.)
progress: number // Progress percentage (0100)
size: number // Total size in bytes
downloaded: number // Downloaded bytes
speed: number // Current speed in bytes/sec
eta: number | null // ETA in seconds, null if unknown
category?: string // Download category (optional)
tags?: string[] // Download tags (optional)
savePath?: string // Save path (optional)
addedOn?: string // Added timestamp (optional)
arrQueueId?: number // Sonarr/Radarr queue ID (optional)
arrType?: 'series' | 'movie' // Sonarr/Radarr type (optional)
raw?: any // Original client response (escape hatch)
}
```
#### Registry (`server/utils/downloadClients.js`)
`DownloadClientRegistry` manages all instances:
```javascript
class DownloadClientRegistry {
async initialize() // Create clients from config
getAllClients(): DownloadClient[]
getClient(instanceId): DownloadClient
getClientsByType(type): DownloadClient[]
async getAllDownloads(): NormalizedDownload[] // Fetch from all clients in parallel
async testAllConnections(): Promise<ConnectionTestResult[]>
async getAllClientStatuses(): Promise<ClientStatus[]>
}
```
**Configuration-driven:** reads from `*_INSTANCES` environment variables (JSON array format) with fallback to legacy `*_URL` / `*_API_KEY` / `*_USERNAME` / `*_PASSWORD` variables.
#### qBittorrent Sync API Details
Each `QBittorrentClient` instance maintains:
- **`lastRid`** — response ID from the previous `sync/maindata` call (starts at `0`).
- **`torrentMap`** — `Map<hash, torrent>` holding the complete state for every known torrent.
- **`fallbackThisCycle`** — boolean tracking whether this poll cycle has already fallen back to the legacy endpoint.
Per-cycle flow:
1. Attempt `GET /api/v2/sync/maindata?rid={lastRid}`.
2. If `full_update` is `true`, rebuild `torrentMap` from scratch.
3. Otherwise merge delta fields into existing entries; remove `torrents_removed` hashes.
4. On Sync API failure, fall back **once per cycle** to `GET /api/v2/torrents/info`.
5. If the fallback also fails, return an empty array for this cycle and log the error.
The rest of the application (poller, dashboard) receives data in the same format regardless of which path was taken.
#### Adding a New Download Client
1. Create `server/clients/MyClient.js` extending `DownloadClient`.
2. Implement `getActiveDownloads()` returning `NormalizedDownload[]`.
3. Register the class in the registry factory inside `server/utils/downloadClients.js`.
---
### 3.2 Pluggable *arr Retrieval Layer (PALDRA)
#### Overview
`server/utils/arrRetrievers.js` exports `arrRetrieverRegistry`, a singleton that manages one `PollingSonarrRetriever` or `PollingRadarrRetriever` per configured instance. It provides a uniform interface for fetching queue, history, and tag data, keyed by service type.
The registry is used by both the background poller and the webhook processor, guaranteeing consistent data shapes across both update paths.
#### Registry API
```javascript
arrRetrieverRegistry = {
async initialize() // idempotent; reads config once
getAllRetrievers(): ArrRetriever[]
getRetriever(instanceId): ArrRetriever | null
getRetrieversByType(type): ArrRetriever[] // 'sonarr' | 'radarr'
// Typed fetch methods — all return { sonarr: [...], radarr: [...] }
async getQueuesByType(): Promise<{ sonarr, radarr }>
async getHistoryByType(options?): Promise<{ sonarr, radarr }>
async getTagsByType(): Promise<{ sonarr, radarr }>
}
```
Each result element is `{ instance: instanceId, data: <arr API response> }`, allowing callers to look up instance credentials from `config.js`.
#### Retriever API Calls
| Task | Endpoint | Key Parameters |
|------|----------|----------------|
| 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 tags | `GET /api/v3/tag` | — |
| Radarr queue | `GET /api/v3/queue` | `includeMovie=true` |
| Radarr history | `GET /api/v3/history` | `pageSize=10` |
All fetches across all instances run in parallel via `Promise.allSettled`, so a single failing instance does not block others.
---
## 4. Webhook System
### 4.1 Webhook Receiver
sofarr exposes two webhook endpoints that Sonarr and Radarr can be configured to call on every automation event:
```
POST /api/webhook/sonarr
POST /api/webhook/radarr
```
Both endpoints share identical processing logic:
```
Sonarr/Radarr
POST /api/webhook/sonarr
Headers: X-Sofarr-Webhook-Secret: <secret>
Body: { "eventType": "Grab", "instanceName": "Main Sonarr",
"date": "2026-05-19T10:00:00.000Z", … }
webhookLimiter (60 req/min/IP)
validateWebhookSecret() ──fail──► 401 Unauthorized
│ ok
validatePayload() ──fail──► 400 Bad Request
│ ok
isReplay() ──yes───► 200 { received: true, duplicate: true }
│ no
cache.updateWebhookMetrics(instance.url) ← activates smart polling skip
200 { received: true } ← response sent immediately
▼ (fire-and-forget)
processWebhookEvent(serviceType, eventType)
├── classify: QUEUE_EVENT or HISTORY_EVENT
├── arrRetrieverRegistry.getQueuesByType() / getHistoryByType()
├── cache.set('poll:sonarr-queue' | 'poll:sonarr-history', …, CACHE_TTL)
└── pollAllServices() → pollSubscribers.forEach(cb) → SSE push
```
The 200 response is sent **before** the background cache refresh completes, ensuring Sonarr/Radarr never have to wait for sofarr's downstream API calls.
#### Event Classification
| Event type | Classification | Cache keys refreshed |
|------------|---------------|---------------------|
| `Grab`, `Download`, `DownloadFailed`, `ManualInteractionRequired` | `QUEUE_EVENT` | `poll:{type}-queue` |
| `DownloadFolderImported`, `ImportFailed`, `EpisodeFileRenamed`, `MovieFileRenamed`, `EpisodeFileRenamedBySeries` | `HISTORY_EVENT` | `poll:{type}-history` |
| `Test`, `Rename`, `SeriesAdd`, `SeriesDelete`, `MovieAdd`, `MovieDelete`, `MovieFileDelete`, `Health`, `ApplicationUpdate`, `HealthRestored` | Informational — no refresh | — |
#### Accepted Event Types
The full allowlist enforced by `validatePayload()`:
```
Test · Grab · Download · DownloadFailed · ManualInteractionRequired
DownloadFolderImported · ImportFailed
EpisodeFileRenamed · MovieFileRenamed · EpisodeFileRenamedBySeries
Rename · SeriesAdd · SeriesDelete · MovieAdd · MovieDelete · MovieFileDelete
Health · ApplicationUpdate · HealthRestored
```
Any `eventType` not in this set is rejected with `400 Bad Request`.
---
### 4.2 Real-time Cache and SSE Integration
When a webhook event is classified as a `QUEUE_EVENT` or `HISTORY_EVENT`:
1. `arrRetrieverRegistry` fetches fresh data from the relevant *arr instances (in parallel, via PALDRA).
2. The result is written directly into the shared `MemoryCache` under the same `poll:*` key the poller uses — ensuring both paths produce identical cache shapes.
3. `pollAllServices()` is called, which iterates `pollSubscribers` and pushes the updated payload to every open SSE connection immediately.
The dashboard therefore receives fresh data within the round-trip time of the *arr API call, without waiting for the next poll cycle.
---
### 4.3 Notification Management API
The `sonarr.js` and `radarr.js` route modules expose endpoints (under `/api/sonarr` and `/api/radarr` respectively, behind `requireAuth` + `verifyCsrf`) for one-click webhook configuration. These proxy to the *arr notification API to create, update, or remove the sofarr webhook connection in Sonarr/Radarr, using `SOFARR_BASE_URL` to construct the target URL.
---
## 5. Data Flow and Real-time Updates
### 5.1 Polling Cycle (background path)
Every `POLL_INTERVAL` ms the poller fetches all services in parallel:
| Task | API | Key parameters |
|------|-----|----------------|
| 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 Tags | `GET /api/v3/tag` | — |
| Radarr Queue | `GET /api/v3/queue` | `includeMovie=true` |
| Radarr History | `GET /api/v3/history` | `pageSize=10` |
| qBittorrent | `GET /api/v2/sync/maindata?rid=N` | Fallback: `GET /api/v2/torrents/info` |
Results are stored in `MemoryCache` under `poll:*` keys with TTL `POLL_INTERVAL × 3`. Per-task timings are recorded in `lastPollTimings` for the admin status panel.
```mermaid
sequenceDiagram
participant Entry as index.js
participant Poller
participant PDCA as PDCA Registry
participant PALDRA as PALDRA Registry
participant Cache as MemoryCache
participant SSE as SSE Subscribers
Entry->>Poller: startPoller()
loop Every POLL_INTERVAL ms
Poller->>Poller: polling flag check (skip if concurrent)
Poller->>PDCA: getDownloadsByClientType()
Poller->>PALDRA: getQueuesByType() / getHistoryByType() / getTagsByType()
PDCA-->>Poller: { sabnzbd, qbittorrent, rtorrent, transmission }
PALDRA-->>Poller: { sonarr: [...], radarr: [...] }
Poller->>Cache: set poll:* keys (TTL = POLL_INTERVAL × 3)
Poller->>SSE: notify all subscribers → push data: frame
end
```
### 5.2 Webhook Path (real-time update)
```mermaid
sequenceDiagram
participant Arr as Sonarr/Radarr
participant WH as /api/webhook/sonarr
participant Cache as MemoryCache
participant PALDRA as PALDRA Registry
participant SSE as SSE Subscribers
Arr->>WH: POST /api/webhook/sonarr { eventType, instanceName, date }
WH->>WH: validateSecret + validatePayload + isReplay
WH->>Cache: updateWebhookMetrics(instance.url)
WH-->>Arr: 200 { received: true }
Note over WH: fire-and-forget begins
WH->>PALDRA: getQueuesByType() or getHistoryByType()
PALDRA-->>WH: fresh arr data
WH->>Cache: set poll:sonarr-queue / poll:sonarr-history
WH->>SSE: pollAllServices() → push data: frame to all clients
```
### 5.3 SSE Stream
When a browser opens `GET /api/dashboard/stream`:
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 `/user-downloads`).
3. Registers a callback with the poller's `onPollComplete` subscriber set.
4. After every subsequent poll cycle (or webhook-triggered broadcast), 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 `activeClients` map.
The browser's native `EventSource` API handles reconnection automatically on network interruption.
### 5.4 Download Matching Pipeline
For each connected user the server:
1. Reads all `poll:*` keys from `MemoryCache`.
2. Builds `seriesMap`, `moviesMap`, `sonarrTagMap`, and `radarrTagMap` from embedded objects in queue records.
3. For each SABnzbd/qBittorrent download item, attempts matches in priority order: Sonarr queue → Radarr queue → Sonarr history → Radarr history.
4. Title matching is a **bidirectional, case-insensitive substring match**: `rTitle.includes(dlTitle) || dlTitle.includes(rTitle)`.
5. For each match, resolves the series/movie, extracts user tags, checks ownership.
6. Returns only the requesting user's downloads (or all, if admin with `showAll=true`).
```mermaid
flowchart TD
Start(["Download item"]) --> SQ{"Sonarr QUEUE\nmatch (title)"}
SQ -->|yes| SQR["Resolve series · extract user tag"]
SQ -->|no| RQ{"Radarr QUEUE\nmatch (title)"}
RQ -->|yes| RQR["Resolve movie · extract user tag"]
RQ -->|no| SH{"Sonarr HISTORY\nmatch (title)"}
SH -->|yes| SHR["Resolve series via seriesId"]
SH -->|no| RH{"Radarr HISTORY\nmatch (title)"}
RH -->|yes| RHR["Resolve movie via movieId"]
RH -->|no| Skip(["Skip — unmatched"])
SQR & RQR & SHR & RHR --> Tagged{"Tag matches\nrequesting user?"}
Tagged -->|yes| Include(["Include in response"])
Tagged -->|no| Skip
```
#### Tag matching
Users are matched to downloads via Sonarr/Radarr tags:
1. **Exact match** — tag label (lowercased) === username (lowercased).
2. **Sanitised match** — handles Ombi tag mangling: `sanitizeTagLabel()` converts to lowercase, replaces non-alphanumeric chars with hyphens, collapses runs, trims.
#### Matched download object fields
| 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}[]` | Episodes covered (sorted); empty array if Sonarr has no data |
| `allTags` | string[] | All resolved tag labels on the series/movie |
| `matchedUserTag` | string/null | Tag label matching the requesting user |
| `tagBadges` | `{label, matchedUser}[]`/undefined | (Admin `showAll` only) each tag classified against Emby user list |
| `importIssues` | string[]/null | Import warning/error messages |
| `canBlocklist` | boolean | `true` if the current user may blocklist this download |
| `downloadPath` | string/null | (Admin) Download client path |
| `targetPath` | string/null | (Admin) *arr target path |
| `arrLink` | string/null | (Admin) Link to *arr web UI |
| `arrQueueId` | number/null | (Admin) Sonarr/Radarr queue record id |
| `arrType` | `'sonarr'`/`'radarr'`/null | (Admin) Which *arr service owns this queue entry |
| `arrInstanceUrl` | string/null | (Admin) Base URL of the *arr instance |
| `arrInstanceKey` | string/null | (Admin) API key for the *arr instance |
| `arrContentId` | number/null | (Admin) `episodeId` or `movieId` for triggering a new search |
| `arrContentType` | `'episode'`/`'movie'`/null | (Admin) Content type for search command |
| `addedOn` | number/null | (qBittorrent) Unix timestamp when torrent was added |
| `availableForUpgrade` | boolean/undefined | (History) `true` when outcome is `failed` but content is on disk |
---
## 6. Caching and Smart Polling
### 6.1 Cache Layer
`server/utils/cache.js` exports a singleton `MemoryCache` backed by a `Map`. Each entry carries an expiration timestamp. The cache is shared by the poller, webhook processor, and all route modules.
```javascript
class MemoryCache {
get(key): any
set(key, value, ttlMs)
invalidate(key)
clear()
getStats(): CacheStats // per-key size, item count, TTL remaining
// Webhook metrics helpers
updateWebhookMetrics(instanceUrl)
getWebhookMetrics(instanceUrl): { eventsReceived, lastWebhookTimestamp, pollsSkipped }
getGlobalWebhookMetrics(): { lastGlobalWebhookTimestamp }
}
```
### 6.2 Cache Keys
| Key | Content | TTL |
|-----|---------|-----|
| `poll:sab-queue` | `{ slots, status, speed, kbpersec }` | `POLL_INTERVAL × 3` |
| `poll:sab-history` | `{ slots }` | `POLL_INTERVAL × 3` |
| `poll:sonarr-queue` | `{ records }` with embedded `series` objects + `_instanceUrl`/`_instanceKey` | `POLL_INTERVAL × 3` |
| `poll:sonarr-history` | `{ records }` — lightweight, no embedded objects | `POLL_INTERVAL × 3` |
| `poll:sonarr-tags` | `[{ instance, data: [{id, label}] }]` | `POLL_INTERVAL × 3` |
| `poll:radarr-queue` | `{ records }` with embedded `movie` objects + `_instanceUrl`/`_instanceKey` | `POLL_INTERVAL × 3` |
| `poll:radarr-history` | `{ records }` — lightweight | `POLL_INTERVAL × 3` |
| `poll:radarr-tags` | `[{ instance, data: [{id, label}] }]` | `POLL_INTERVAL × 3` |
| `poll:qbittorrent` | `[torrent, …]` | `POLL_INTERVAL × 3` |
| `history:sonarr` | `[record, …]` flat array with `_instanceUrl`/`_instanceName` | 5 min |
| `history:radarr` | `[record, …]` flat array with `_instanceUrl`/`_instanceName` | 5 min |
| `emby:users` | `Map<lowerName, displayName>` | 60 s |
When polling is disabled (`POLL_INTERVAL=0`), all `poll:*` TTLs fall back to **30 s** and data is fetched on-demand when the dashboard finds an empty cache entry.
### 6.3 Background Polling Modes
| Mode | `POLL_INTERVAL` | Behaviour |
|------|----------------|-----------|
| **Background** | `> 0` (e.g. `5000`) | Periodic fetch every N ms; SSE subscribers notified after each cycle |
| **On-demand** | `0` / `off` / `false` | Fetch triggered by first dashboard request when cache is empty; cached 30 s |
The poller uses a `polling` boolean flag to prevent concurrent cycles: if an interval fires while the previous poll is still running, the new invocation is skipped and logged.
### 6.4 Smart Polling Optimisation
When Sonarr/Radarr are configured to send webhooks to sofarr, the poller automatically reduces unnecessary API calls:
```
pollAllServices() called every POLL_INTERVAL ms:
globalMetrics = cache.getGlobalWebhookMetrics()
fallbackTriggered = lastGlobalWebhookTimestamp > WEBHOOK_FALLBACK_TIMEOUT ago
for each service type (sonarr, radarr):
shouldSkip = !fallbackTriggered
&& all instances have metrics.eventsReceived > 0
&& all instances have metrics.lastWebhookTimestamp within WEBHOOK_FALLBACK_TIMEOUT
if shouldSkip:
extend TTL of existing cached data ← zero *arr API calls
increment metrics.pollsSkipped
log "[Poller] Skipping sonarr polling for N instance(s) with active webhooks"
else:
fetch from *arr APIs → update cache
```
**Effect:** zero *arr API calls per poll cycle when webhooks are active and recent. The poller automatically falls back to full polling after `WEBHOOK_FALLBACK_TIMEOUT` minutes of silence (default: 10 minutes), ensuring the dashboard remains accurate even if webhooks stop arriving.
### 6.5 Active SSE Client Tracking
SSE connections are tracked precisely in `activeClients` (a `Map` keyed by `${username}:${connectedAt}`): registered on connect, removed on disconnect. The admin status panel shows each connected user and their connection duration. The `type: 'sse'` field distinguishes SSE clients from other connection types.
---
## 7. Key Subsystems
### 7.1 Download Clients
See [Section 3.1](#31-pluggable-download-client-architecture-pdca) for full detail. The client hierarchy is:
```
DownloadClient (abstract — server/clients/DownloadClient.js)
├── SABnzbdClient.js — Usenet; REST; API key auth
├── QBittorrentClient.js — Torrent; Sync API + fallback; cookie auth
├── TransmissionClient.js — Torrent; JSON-RPC; session-ID management
└── RTorrentClient.js — Torrent; XML-RPC; HTTP Basic Auth
```
`server/utils/qbittorrent.js` is a legacy compatibility shim that delegates to `QBittorrentClient`.
### 7.2 Queue & History Processing
**`server/utils/historyFetcher.js`** fetches history records from all Sonarr/Radarr instances for a configurable date window. Results are cached under `history:sonarr` / `history:radarr` for 5 minutes. Exports `classifySonarrEvent` / `classifyRadarrEvent` (returns `'imported'` | `'failed'` | `'other'`) and `invalidateHistoryCache`.
**`server/routes/history.js`** (`GET /api/history/recent`) returns recently completed (imported or failed) downloads filtered for the authenticated user. Supports `?days=N` (default `RECENT_COMPLETED_DAYS`, capped at 90) and `?showAll=true` for admins. Results are sorted newest first.
**`server/routes/dashboard.js`** (`POST /api/dashboard/blocklist-search`) removes a Sonarr/Radarr queue item with `blocklist=true` and immediately triggers an `EpisodeSearch` or `MoviesSearch` command. Non-admin users may only blocklist when import issues are present, or (for qBittorrent only) the torrent is over 1 hour old with less than 100% availability.
### 7.3 Dashboard & Frontend
The frontend is a **vanilla JavaScript SPA** with no build step. All logic resides in `public/app.js`, styled by `public/style.css`, and structured by `public/index.html`. Three CSS themes are available via the `data-theme` attribute on `<html>` and persist in `localStorage`:
- **Light** — Purple gradient header, white cards
- **Dark** — Dark surfaces, muted accents
- **Mono** — Monochrome, minimal colour
#### UI state machine
```mermaid
stateDiagram-v2
[*] --> SplashScreen : Page load
SplashScreen --> CheckAuth : checkAuthentication()
state CheckAuth <<choice>>
CheckAuth --> LoginForm : No session
CheckAuth --> Dashboard : Valid session
LoginForm --> Dashboard : Auth success (fade transition)
Dashboard --> LoginForm : Logout (stopSSE)
state Dashboard {
[*] --> Rendering
Rendering --> Rendering : SSE message → renderDownloads()
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
}
}
```
#### 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`; handle incoming data |
| `stopSSE()` | Close `EventSource` and cancel reconnect timer |
| `renderDownloads()` | Diff-based card rendering (create/update/remove) |
| `createDownloadCard()` | Build DOM for a single card; renders tag badges, import-issue badge, blocklist button |
| `updateDownloadCard()` | Update existing card in-place (progress, speed, etc.) |
| `handleBlocklistSearch()` | Confirm dialog → POST `/blocklist-search` → update button state |
| `toggleStatusPanel()` | Show/hide admin status panel |
| `renderStatusPanel()` | Build status HTML (server, polling, SSE clients, cache) |
| `initThemeSwitcher()` | Light / Dark / Mono theme support |
| `loadHistory()` | Fetch `/api/history/recent`, store raw items, call `renderHistory()` |
| `renderHistory()` | Filter items by `ignoreAvailable` flag, render history cards |
#### Tag badge rendering
- **Regular 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 (leftmost); tags matched to a known Emby user → accent badge showing the Emby display name (rightmost).
---
## 8. Directory Structure
```
sofarr/
├── server/
│ ├── app.js Express app factory — imported by tests and index.js
│ ├── index.js Entry point: logging setup, server listen, poller start
│ ├── clients/ PDCA — one file per download client + retriever
│ │ ├── DownloadClient.js Abstract base class for all download clients
│ │ ├── QBittorrentClient.js
│ │ ├── SABnzbdClient.js
│ │ ├── TransmissionClient.js
│ │ ├── RTorrentClient.js
│ │ ├── PollingSonarrRetriever.js PALDRA — Sonarr retriever
│ │ └── PollingRadarrRetriever.js PALDRA — Radarr retriever
│ ├── routes/
│ │ ├── auth.js POST /login, GET /me, GET /csrf, POST /logout
│ │ ├── dashboard.js SSE /stream, /user-downloads, /user-summary, /status, /cover-art, /blocklist-search
│ │ ├── history.js GET /api/history/recent
│ │ ├── webhook.js POST /api/webhook/sonarr|radarr
│ │ ├── sonarr.js Sonarr API proxy + webhook management
│ │ ├── radarr.js Radarr API proxy + webhook management
│ │ ├── emby.js Emby API proxy
│ │ └── sabnzbd.js SABnzbd API proxy
│ ├── middleware/
│ │ ├── requireAuth.js httpOnly cookie auth enforcement
│ │ └── verifyCsrf.js Double-submit CSRF check (timing-safe)
│ └── utils/
│ ├── arrRetrievers.js PALDRA registry — Sonarr/Radarr fetch registry
│ ├── cache.js MemoryCache + webhook metrics helpers
│ ├── config.js Multi-instance config parser
│ ├── downloadClients.js PDCA registry + factory
│ ├── historyFetcher.js History fetch + event classification
│ ├── logger.js File logger (DATA_DIR/server.log)
│ ├── poller.js Smart background polling engine
│ ├── qbittorrent.js Legacy compatibility shim → QBittorrentClient
│ ├── sanitizeError.js Secret redaction from errors/logs
│ └── tokenStore.js Emby token store (JSON file, atomic writes, 31-day TTL)
├── public/ Static SPA (served by Express)
│ ├── index.html HTML shell: splash, login, dashboard
│ ├── app.js All frontend logic
│ ├── style.css Themes, layout, responsive design
│ ├── favicon.ico / *.png Favicons
│ └── images/ Logo / splash screen assets
├── tests/
│ ├── README.md Testing approach and coverage targets
│ ├── setup.js Global setup: isolated DATA_DIR, rate-limit bypass
│ ├── unit/ Pure unit tests (no HTTP)
│ └── integration/ Supertest + nock integration tests
├── .gitea/workflows/
│ ├── ci.yml Security audit + test/coverage on every push/PR
│ ├── build-image.yml Docker image build and push
│ ├── create-release.yml Release tagging workflow
│ ├── docs-check.yml Markdown lint + Mermaid validation
│ └── licence-check.yml Production dependency licence check
├── Dockerfile Multi-stage production 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
├── ARCHITECTURE.md This document
├── SECURITY.md Threat model and hardening guide
├── CHANGELOG.md Version history
└── .env.sample Annotated environment variable template
```
---
## 9. Configuration and Environment Variables
### 9.1 Core Server
| Variable | Required | Default | Description |
|----------|:--------:|---------|-------------|
| `PORT` | No | `3001` | Server listen port |
| `NODE_ENV` | No | — | Set to `production` for production logging and startup validation |
| `DATA_DIR` | No | `./data` | Directory for `tokens.json` and `server.log`. Must be writable. In Docker: `/app/data` (named volume). |
| `COOKIE_SECRET` | No* | — | Signs all session cookies with HMAC-SHA256. **Strongly recommended in production** (server exits on startup if unset in `NODE_ENV=production`). Generate with `openssl rand -hex 32`. |
| `TRUST_PROXY` | No | — | Express `trust proxy` setting. Set to `1` when behind a TLS-terminating reverse proxy (nginx, Caddy, Traefik) so `req.ip` and `req.secure` are correct. |
| `LOG_LEVEL` | No | `info` | `debug`, `info`, `warn`, `error`, `silent` |
| `RECENT_COMPLETED_DAYS` | No | `7` | Default lookback window for `/api/history/recent`. Overridable per-request via `?days=`. Capped at 90. |
### 9.2 TLS / HTTPS
| Variable | Required | Default | Description |
|----------|:--------:|---------|-------------|
| `TLS_ENABLED` | No | `true` | Set to `false` to run plain HTTP (e.g. when TLS is terminated by a reverse proxy). |
| `TLS_CERT` | No | `certs/snakeoil.crt` | Path to TLS certificate (PEM). Defaults to the bundled self-signed snakeoil certificate. |
| `TLS_KEY` | No | `certs/snakeoil.key` | Path to TLS private key (PEM). |
### 9.3 Webhook
| Variable | Required | Default | Description |
|----------|:--------:|---------|-------------|
| `SOFARR_WEBHOOK_SECRET` | Yes* | — | Shared secret validated on the `X-Sofarr-Webhook-Secret` header. Webhook endpoints reject all requests if this is not set. Generate with `openssl rand -hex 32`. |
| `SOFARR_BASE_URL` | Yes* | — | Public base URL of this sofarr instance (e.g. `https://sofarr.example.com`). Used by the one-click webhook configuration endpoints to tell Sonarr/Radarr where to send events. |
| `WEBHOOK_FALLBACK_TIMEOUT` | No | `10` | Minutes of silence after which the poller falls back to full polling even when webhooks were recently active. |
### 9.4 Polling
| Variable | Required | Default | Description |
|----------|:--------:|---------|-------------|
| `POLL_INTERVAL` | No | `5000` | Background poll interval in ms. Set to `0`, `off`, or `false` to disable and use on-demand mode. |
### 9.5 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 |
### 9.6 Service Instances
All service instances support both a JSON array format (recommended) and a legacy single-instance format:
| Variable | Required | Format |
|----------|:--------:|--------|
| `SONARR_INSTANCES` | Yes* | JSON array |
| `SONARR_URL` + `SONARR_API_KEY` | Yes* | Legacy single-instance |
| `RADARR_INSTANCES` | Yes* | JSON array |
| `RADARR_URL` + `RADARR_API_KEY` | Yes* | Legacy single-instance |
| `SABNZBD_INSTANCES` | Yes* | JSON array |
| `SABNZBD_URL` + `SABNZBD_API_KEY` | Yes* | Legacy single-instance |
| `QBITTORRENT_INSTANCES` | No | JSON array (uses `username`/`password` not `apiKey`) |
| `RTORRENT_INSTANCES` | No | JSON array (URL must include the full XML-RPC path, e.g. `/RPC2`) |
\* Either `*_INSTANCES` or the legacy pair is required for each service.
#### JSON array instance 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 and rTorrent instances use `username` and `password` instead of `apiKey`.
Each instance receives an `id` derived from `name` (or index if unnamed), used as the key in PDCA and PALDRA registries.
---
## 10. Security Model
### 10.1 Authentication and Sessions
| Concern | Mechanism |
|---------|-----------|
| **User authentication** | Emby credentials via `POST /Users/authenticatebyname`. A deterministic `DeviceId` (SHA-256 of username, first 16 chars) ensures Emby reuses the same session on every login. |
| **Session cookie** | `httpOnly`, `sameSite: strict`, `secure` when `TRUST_PROXY` is set. Payload: `{ id, name, isAdmin }` only — the Emby `AccessToken` is **never** sent to the browser. Signed with HMAC when `COOKIE_SECRET` is set. |
| **Token store** | Emby `AccessToken`s stored server-side in `DATA_DIR/tokens.json` (atomic writes, 31-day TTL, hourly pruning). Used only for server-side Emby logout. |
| **Session validation** | `requireAuth` middleware on all `/api/dashboard`, `/api/history`, and proxy routes. Returns `401` if the cookie is absent, tampered, or schema-invalid. |
| **CSRF protection** | Double-submit cookie pattern. `verifyCsrf` middleware compares the `csrf_token` cookie against the `X-CSRF-Token` request header using `crypto.timingSafeEqual`. Applied to all state-changing requests (`POST`, `PUT`, `PATCH`, `DELETE`) under `/api/*` except auth and webhook routes. |
| **Remember-me** | `rememberMe: true` → persistent cookie, `Max-Age` 30 days. `rememberMe: false` → session cookie (expires on browser close). |
### 10.2 Webhook Security
| Concern | Mechanism |
|---------|-----------|
| **Secret validation** | Every webhook request must carry `X-Sofarr-Webhook-Secret` matching `SOFARR_WEBHOOK_SECRET`. Absent or wrong secret → `401`. Webhook endpoints function outside the CSRF middleware (they are not browser-initiated). |
| **Rate limiting** | Dedicated `webhookLimiter`: 60 req/min per IP (stricter than the general 300 req/15 min limiter). |
| **Payload validation** | `validatePayload()` enforces: JSON object body, `eventType` as a non-empty string ≤ 64 chars, `eventType` in the allowlist, `instanceName` as string if present. Rejects with `400` on any violation. |
| **Replay protection** | `isReplay()` caches a composite key `{eventType}:{instanceName}:{date}` for 5 minutes. Duplicate events within that window are acknowledged with `200 { received: true, duplicate: true }` and not processed. |
### 10.3 Additional Security Measures
| Concern | Mechanism |
|---------|-----------|
| **Rate limiting** | 300 req/15 min general (all API routes); 10 failed attempts/15 min login limiter; 60 req/1 min webhook limiter. |
| **Secret leakage** | `sanitizeError()` (`server/utils/sanitizeError.js`) redacts secrets from error messages and logs: URL query-param secrets (`apikey=`, `token=`), HTTP auth headers (`Authorization:`, `X-Emby-Authorization:`), Bearer tokens, and basic-auth credentials in URLs. |
| **HTTP headers** | Helmet v7: CSP with per-request nonce (`crypto.randomBytes(16)` for inline styles/scripts), HSTS, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `Permissions-Policy`. |
| **Body size** | `express.json` body limit: 64 KB. |
| **Authorisation matrix** | Regular users see only their own downloads. Admins can view all users, see paths and *arr links, and blocklist any download. Non-admins can only blocklist when import issues exist or (for qBittorrent) the torrent is >1 h old with <100% availability. |
| **Container security** | Docker image runs as the non-root `node` user (UID 1000). `/app/data` is owned by `node`. |
---
## 11. Technology Stack
### Runtime and Framework
| Layer | Technology | Notes |
|-------|-----------|-------|
| Runtime | Node.js 22 (Alpine) | LTS; ESM-ready; V8 coverage built-in |
| Framework | Express 4.x | HTTP server, routing, middleware |
| HTTP client | axios 1.x | External API communication |
| XML-RPC client | xmlrpc 1.3.2 | rTorrent communication |
| Frontend | Vanilla JS + CSS | SPA, no build step required |
| Containerisation | Docker multi-stage (node:22-alpine) | Non-root `node` user; minimal image |
| Logging | Custom logger + `console.*` redirection | File + stdout with configurable levels |
### Security Middleware
| Package | Version | Purpose |
|---------|---------|---------|
| `helmet` | 7.x | HTTP security headers (CSP nonce, HSTS, referrer policy, frame options) |
| `express-rate-limit` | 7.x | General, login, and webhook rate limiters |
| `cookie-parser` | 1.x | Signed cookie support (HMAC via `COOKIE_SECRET`) |
### Auth and Session
| Component | Technology | Details |
|-----------|-----------|---------|
| Identity provider | Emby / Jellyfin API | `POST /Users/authenticatebyname` |
| Session cookie | `httpOnly` + `sameSite: strict` | Signed when `COOKIE_SECRET` is set |
| CSRF protection | Double-submit cookie | `csrf_token` cookie + `X-CSRF-Token` header; `crypto.timingSafeEqual` |
| Token store | JSON file (`DATA_DIR/tokens.json`) | Atomic writes, 31-day TTL, hourly pruning |
### Testing
| Tool | Version | Purpose |
|------|---------|---------|
| `vitest` | 4.x | Test runner with V8 coverage; per-file coverage thresholds in `vitest.config.js` |
| `supertest` | 7.x | HTTP integration testing against the Express app factory |
| `nock` | 14.x | HTTP interception at Node layer (compatible with CJS `require('axios')`) |
### CI/CD
| Workflow file | Trigger | Purpose |
|---------------|---------|---------|
| `ci.yml` | Every push / PR | `npm audit --audit-level=high` + full test suite with V8 coverage |
| `build-image.yml` | Push to `release/**` or `develop` | Build and push Docker image to registry |
| `create-release.yml` | Tag push (`v*`) | Generate release notes and create a Gitea release |
| `docs-check.yml` | Push / PR touching `**.md` | Markdown lint + Mermaid diagram parse validation |
| `licence-check.yml` | Push / PR touching `package.json` | Verify production dependency licences are MIT-compatible |
-212
View File
@@ -1,212 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [1.5.3] - 2026-05-19
### Fixed
- **Status panel rendering regression** — the status panel was rendering as a small blank box due to an undefined `--background` CSS variable. Added the missing variable to all three themes (light, dark, mono).
- **Status panel content destruction** — `showDashboard()` was calling `sp.innerHTML = ''` which destroyed the `status-content` div inside the status panel. Removed this destructive line.
- **Webhooks panel visibility sync** — the webhooks panel was incorrectly visible on app load. Added explicit hiding of `webhooks-section` in `showDashboard()` to keep it in sync with the status panel (both show/hide together via `toggleStatusPanel()`).
- **Webhooks panel DOM structure** — reverted webhooks-section to be a sibling of status-panel (not nested inside it), preventing innerHTML operations from affecting webhook elements.
---
## [1.5.2] - 2026-05-19
### Fixed
- **Status panel close button CSP compliance** — replaced inline `onclick` handler with `addEventListener` to comply with CSP nonce policy.
---
## [1.5.1] - 2026-05-19
### Fixed
- **Webhook endpoints not reachable in production** — `server/index.js` (the production entry point) was missing the `webhookRoutes` import and mount. Only `server/app.js` (the test factory) had the routes registered. As a result every `POST /api/webhook/*` request in a running container fell through to the `verifyCsrf` middleware and was rejected with `403 CSRF token missing`. Added `app.use('/api/webhook', webhookRoutes)` in `index.js` immediately after `authRoutes` and before `verifyCsrf`, matching the order in `app.js`.
---
## [1.5.0a] - 2026-05-19
### Fixed
- **Status panel close button** — the `×` button now correctly hides the status panel and stops the auto-refresh timer. The button was previously using an inline `onclick` attribute which was silently blocked by the server's CSP nonce policy. Replaced with `addEventListener` wired after `innerHTML` is set, consistent with all other button handlers in the application.
---
## [1.5.0] - 2026-05-19
### Changed
- **`ARCHITECTURE.md`** — consolidated the two existing architecture documents (root concise reference and `docs/ARCHITECTURE.md` deep-dive) into a single comprehensive reference at the project root. The merged document covers all 11 sections: Introduction, High-Level Architecture, Pluggable Architecture Layers (PDCA + PALDRA), Webhook System, Data Flow and Real-time Updates, Caching and Smart Polling, Key Subsystems, Directory Structure, Configuration and Environment Variables, Security Model, and Technology Stack. Includes full Mermaid diagrams for system overview, polling cycle, webhook path, UI state machine, and download matching pipeline.
- **`docs/ARCHITECTURE.md`** — removed; content fully merged into root `ARCHITECTURE.md`.
---
## [1.4.0] - 2026-05-19
### Added
#### Webhook Integration (Phases 15.1)
- **Webhook receiver endpoints** — `POST /api/webhook/sonarr` and `POST /api/webhook/radarr` accept push events from Sonarr and Radarr with shared-secret validation (`X-Sofarr-Webhook-Secret` header). Dashboard updates in < 1 second after any grab, import, or failure.
- **Selective cache invalidation** — each incoming event is classified into *queue events* (`Grab`, `Download`, `DownloadFailed`, `ManualInteractionRequired`) or *history events* (`DownloadFolderImported`, `ImportFailed`, `EpisodeFileRenamed`, `MovieFileRenamed`). Only the affected cache key is refreshed via a lightweight re-poll of that instance, rather than a full poll cycle.
- **SSE broadcast on webhook** — after refreshing the cache, `pollAllServices()` is called as a fire-and-forget, triggering an immediate SSE push to every connected browser.
- **Notification management API proxy** — `GET /api/sonarr/api/v3/notification` and `POST /api/sonarr/api/v3/notification` (and Radarr equivalents) proxy the full *arr Notification API through sofarr with auth + CSRF enforcement.
- **One-click webhook setup** — `POST /api/sonarr/webhook/enable` and `POST /api/radarr/webhook/enable` auto-configure the Sofarr webhook notification connection inside the respective *arr service. `POST /api/sonarr/webhook/test` and `/radarr/webhook/test` trigger a test event.
- **Webhooks Configuration UI** — collapsible panel in the dashboard allowing users to enable, test, and view webhook status for each configured Sonarr/Radarr instance, with per-trigger type indicators showing which event types are active.
- **`SOFARR_WEBHOOK_SECRET`** environment variable — required for webhook endpoints to accept requests. Generate with `openssl rand -hex 32`.
- **`SOFARR_BASE_URL`** environment variable — public URL of sofarr, used by the one-click setup to tell Sonarr/Radarr where to POST events.
#### Smart Polling Optimization (Phase 5)
- **Webhook metrics tracking** — `cache.js` now maintains per-instance and global webhook metrics (`lastWebhookTimestamp`, `eventsReceived`, `pollsSkipped`) via `getWebhookMetrics()`, `updateWebhookMetrics()`, `incrementPollsSkipped()`, `getGlobalWebhookMetrics()`.
- **Conditional poll skipping** — `poller.js` calls `shouldSkipInstancePolling()` before each Sonarr/Radarr fetch. If all instances of a type have received a webhook event within the fallback timeout window, their queue/history API calls are skipped entirely; existing cached data has its TTL extended instead.
- **Webhook fallback** — if no webhook events have been received globally for `WEBHOOK_FALLBACK_TIMEOUT` minutes (default: 10), the poller forces a full poll regardless of per-instance state. Logged as `[Poller] Webhook fallback triggered`.
- **Poll-skip logging** — logs `[Poller] Skipping sonarr/radarr polling for N instance(s) with active webhooks` when polling is skipped.
- **`WEBHOOK_FALLBACK_TIMEOUT`** environment variable — minutes before fallback polling (default: `10`).
- **`WEBHOOK_POLL_INTERVAL_MULTIPLIER`** environment variable — internal multiplier for TTL calculations when webhooks are active (default: `3`).
- **Phase 5.1 metrics connection** — `webhook.js` calls `cache.updateWebhookMetrics(instance.url)` after every successfully validated event, activating the smart skip logic for that instance.
#### Security Hardening (Phase 6)
- **Dedicated webhook rate limiter** — 60 requests per minute per IP on `/api/webhook/*`, stricter than the global 300/15 min API limiter. Bypassed in tests via `SKIP_RATE_LIMIT=1`.
- **Strict input validation** — `validatePayload()` rejects: non-object bodies, missing/non-string/overlong `eventType`, unrecognised event type values (allowlist of 18 known *arr event types), non-string `instanceName`. Returns `400` with a descriptive message.
- **Replay protection** — `isReplay()` tracks recently-seen `(eventType, instanceName, date)` tuples in a `Map` with a 5-minute TTL. Duplicate events within the window return `200 { received: true, duplicate: true }` without triggering a cache refresh or SSE broadcast.
- **35 new webhook integration tests** — cover secret validation (missing/wrong/unconfigured), payload validation (all invalid cases), replay protection, happy-path acceptance of all relevant event types, metrics increment, and secret-never-leaks assertions.
#### Documentation (Phase 6)
- **`README.md`** — updated architecture overview diagram, added Webhooks section with quick-setup guide, added PDCA/PALDRA/webhook architecture table, added Webhooks & Smart Polling env var section, added webhook API endpoints, updated test count.
- **`CHANGELOG.md`** — this entry.
- **`SECURITY.md`** — added webhook threat model rows, webhook-specific hardening checklist, and rate-limit table entry for webhook endpoints.
- **`ARCHITECTURE.md`** (root) — new concise top-level architecture reference describing all pluggable layers and the full webhook + polling optimization flow.
- **`.env.sample`** — added `WEBHOOK_FALLBACK_TIMEOUT` and `WEBHOOK_POLL_INTERVAL_MULTIPLIER` with explanatory comments; updated NOTES section.
### Changed
- `poller.js``pollAllServices()` now conditionally skips Sonarr/Radarr fetches when webhooks are active; extends TTL of existing cache entries instead of overwriting with empty data.
- `cache.js` — exports four new webhook metrics helpers alongside the existing `MemoryCache` singleton.
- `webhook.js` — imported `express-rate-limit`; added `validatePayload()`, `isReplay()`, `VALID_EVENT_TYPES` allowlist, `recentEvents` Map, and per-route `webhookLimiter` middleware.
---
## [1.3.0] - 2026-05-17
### Added
- **History tab** — new "Recently Completed" tab showing imported and failed downloads from Sonarr/Radarr history for the last N days (configurable via the days input, persisted in `localStorage`). Auto-refreshes every 5 minutes.
- **History deduplication** — when a failed download has subsequently been imported successfully, only the successful record is shown. If the most recent record for an item is a failure but the episode/movie is already on disk (upgrade attempt), the record is flagged as `availableForUpgrade`.
- **"Upgrade available" badge** — failed history cards where the content is already on disk display an amber badge to indicate this is a failed upgrade rather than a missing item.
- **"Hide upgrade failures" toggle** — checkbox in the history tab to filter out failed records that are already available on disk. State persists in `localStorage`. Tooltip explains the behaviour and matches the episode/multi-episode tooltip style.
- **Blocklist & Search button** — admin-only button on download cards with an "Import Pending" caution. Removes the download from the client with `blocklist=true` (preventing re-grab of the same release) then immediately triggers an `EpisodeSearch`/`MoviesSearch` command in Sonarr/Radarr. Shows a confirmation dialog, loading/success/error states. Kicks a background poll on success.
- **`POST /api/dashboard/blocklist-search`** — new admin-only endpoint backing the above button. Accepts `arrQueueId`, `arrType`, `arrInstanceUrl`, `arrInstanceKey`, `arrContentId`, `arrContentType`.
- **Title link home navigation** — the sofarr logo/title in the header now navigates to the default view (Active Downloads tab, close status panel, reset "Show all users" toggle) without a page reload.
- **Version footer link** — the version string in the dashboard footer links to the source repository.
### Changed
- History records are now deduplicated server-side before being sent to the client — only the most relevant record per content item per instance is returned.
- Import-issue badge tooltip now uses the themed `var(--surface)` / `var(--text-primary)` / `var(--border)` CSS variables, matching the episode and toggle tooltip style.
- Poller now stores `_instanceKey` on Sonarr/Radarr queue records in the cache, enabling the backend to look up API credentials for blocklist operations without an additional configuration lookup.
- **Blocklist & Search button** — now available on all admin downloads (not just those with import issues), and also available to non-admin users when: import issues are present, OR (for qBittorrent torrents only) the download is more than 1 hour old AND has less than 100% availability.
- **Download object** — added `canBlocklist` boolean field to indicate whether the current user can blocklist a given download.
- **qBittorrent torrent data** — added `addedOn` timestamp field to enable age-based blocklist eligibility checks.
---
## [1.2.2] - 2026-05-17
### Changed
- **Header logo** — uses the higher-resolution 192px favicon source rendered at 56px for better visual balance alongside the title text.
---
## [1.2.1] - 2026-05-17
### Added
- **Version footer** — the dashboard footer now displays the running app version (e.g. `sofarr v1.2.1`), fetched from the `/health` endpoint on page load.
---
## [1.2.0] - 2025-05-17
### Security
- **Docker secrets support** — all sensitive environment variables (`COOKIE_SECRET`, `EMBY_API_KEY`, `SABNZBD_API_KEY`, `SONARR_API_KEY`, `RADARR_API_KEY`, `QBITTORRENT_PASSWORD`) now support the standard `_FILE` variant for loading values from mounted secret files (e.g. `COOKIE_SECRET_FILE=/run/secrets/cookie_secret`).
- **Weak secret warning** — server now warns at startup if `COOKIE_SECRET` is shorter than 32 characters.
- **EMBY_URL validation** — validates the Emby URL scheme at startup and warns on misconfiguration.
- **Improved error sanitization** — `sanitizeError()` now also redacts hostnames from full request URLs that may appear in axios error messages.
- **Graceful shutdown** — `SIGTERM` and `SIGINT` handlers now stop the background poller and drain open HTTP connections before exiting. Prevents data loss and zombie processes on `docker stop`.
### Compliance
- **MIT LICENSE file** added to project root.
- **Copyright headers** added to key server source files (`index.js`, `poller.js`, `config.js`, `sanitizeError.js`, `loadSecrets.js`).
- **`security.txt`** (`/.well-known/security.txt`) added for responsible disclosure.
### Configuration
- **URL validation** added to `config.js` — all configured service instance URLs are validated for scheme (`http`/`https`) and well-formedness at startup; malformed URLs emit a warning instead of crashing.
### Docker / Deployment
- **`docker-compose.yaml`** updated with commented Option B (Docker secrets `_FILE` pattern) alongside the existing plain-env Option A.
- **`.dockerignore`** updated — `tests/`, `coverage/`, `vitest.config.js`, `CHANGELOG.md`, `SECURITY.md`, `LICENSE`, `.markdownlint.json` excluded from the production image.
### CI
- **`docs-check` workflow** added — separate Gitea Actions workflow that lints all Markdown files and validates Mermaid diagram syntax on every push that touches `.md` files. Both jobs use `continue-on-error: true` so documentation issues never block a release.
- **Mermaid diagrams** in `docs/ARCHITECTURE.md` fixed — replaced invalid `\n` in stateDiagram transition labels, Unicode arrows/dashes, and double-spaces in flowchart edge definitions.
---
## [1.1.2] - 2025-05-15
### Changed
- Server startup message now includes the current version (`sofarr v1.1.2`).
---
## [1.1.1] - 2025-05-14
### Fixed
- Docker/TrueNAS SCALE healthcheck: dynamic HTTP/HTTPS selection based on `TLS_ENABLED` environment variable. Prevents containers from being stuck in "starting" state when `TLS_ENABLED=false`.
---
## [1.1.0] - 2025-05-13
### Added
- **Episode display** — TV show download cards now show episode information (S01E01 format with title). Multi-episode packs show a "Multiple episodes" badge with a tooltip listing all episodes.
- **Episode tooltip** — solid background colour (theme-dependent) for readability.
- Sonarr queue and history API requests now include `includeEpisode=true`.
---
## [1.0.0] - 2025-05-01
### Added
- Initial release.
- SABnzbd queue and history integration.
- qBittorrent torrent integration.
- Sonarr and Radarr queue/history matching with user tag filtering.
- Emby/Jellyfin authentication.
- Server-Sent Events (SSE) real-time dashboard.
- Per-request CSP nonce, CSRF double-submit, HSTS, Permissions-Policy.
- Background polling with configurable interval and on-demand fallback.
- Docker multi-stage build, non-root user, read-only filesystem.
- TLS support with bundled snakeoil certificate.
+8 -40
View File
@@ -1,18 +1,4 @@
# ---------------------------------------------------------------------------
# Stage 1 — deps: install production dependencies only
# ---------------------------------------------------------------------------
FROM node:22-alpine AS deps
WORKDIR /app
# All dependencies are pure JavaScript — no native addons, no build tools.
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# ---------------------------------------------------------------------------
# Stage 2 — runtime image (minimal attack surface)
# ---------------------------------------------------------------------------
FROM node:22-alpine AS runtime
FROM node:18-alpine
LABEL org.opencontainers.image.title="sofarr"
LABEL org.opencontainers.image.description="Personal media download dashboard for *arr services"
@@ -23,36 +9,18 @@ LABEL org.opencontainers.image.vendor="Gordon Bolton"
LABEL org.opencontainers.image.licenses="MIT"
LABEL custom.hardware.requirement="None - runs on any Docker-supported platform including ARM and x86_64"
# Use the built-in non-root 'node' user (UID 1000) from the official image
# The /app directory is owned by root; data directory is owned by node
WORKDIR /app
# Copy production deps from deps stage
COPY --from=deps /app/node_modules ./node_modules
# Copy package files and install dependencies
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Copy application source owned by root (read-only at runtime)
COPY --chown=root:root server/ ./server/
COPY --chown=root:root public/ ./public/
COPY --chown=root:root package.json ./
# Bundled snakeoil certificate for out-of-the-box TLS (self-signed, localhost only).
# Mount your own cert/key over /app/certs/ or set TLS_CERT/TLS_KEY env vars.
COPY --chown=root:root certs/ ./certs/
# Persistent data directory owned by node user (token store, logs)
RUN mkdir -p /app/data && chown node:node /app/data
ENV NODE_ENV=production
ENV DATA_DIR=/app/data
# Drop to non-root user for all subsequent operations
USER node
# Copy application source
COPY server/ ./server/
COPY public/ ./public/
EXPOSE 3001
# HEALTHCHECK — Docker will restart the container if this fails 3 times.
# Respects TLS_ENABLED at runtime: uses https (with --no-check-certificate
# to handle self-signed/snakeoil certs) when TLS is on, plain http when off.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD /bin/sh -c '[ "${TLS_ENABLED:-true}" = "false" ] && wget -qO- http://localhost:${PORT:-3001}/health || wget -qO- --no-check-certificate https://localhost:${PORT:-3001}/health'
ENV NODE_ENV=production
CMD ["node", "server/index.js"]
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Gordon Bolton
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+29 -169
View File
@@ -4,76 +4,39 @@
**sofarr** is a personal media download dashboard that aggregates and displays real-time download progress from all your media automation services. Named for the experience of checking what has downloaded "so far" while you wait comfortably on your "sofa" for Sonarr, Radarr, and your download clients to do their thing!
Version 1.5.x adds **real-time webhook integration**: Sonarr and Radarr can push events directly to sofarr, eliminating polling latency and automatically reducing background API calls when webhooks are active.
## What It Does
sofarr connects to your media stack and shows you a personalized view of:
- **Active Downloads** - See what's currently downloading from Usenet (SABnzbd) and BitTorrent (qBittorrent, Transmission, rTorrent)
- **Active Downloads** - See what's currently downloading from Usenet (SABnzbd) and BitTorrent (qBittorrent)
- **Progress Tracking** - Real-time progress bars with speed, ETA, and completion estimates
- **Recently Completed** - History tab showing imported and failed downloads from Sonarr/Radarr with deduplication and upgrade-awareness
- **User Matching** - Downloads are matched to you based on tags in Sonarr/Radarr
- **Multi-Instance Support** - Connect to multiple instances of each service
- **Webhook Push Updates** - Sonarr/Radarr push events instantly to sofarr; dashboard updates in < 1s after a grab or import
- **Smart Polling** - Background polling automatically reduces (or skips) API calls for instances with active webhooks, with configurable fallback
## How It Works
### Architecture Overview
```
┌─────────────┐ ┌──────────────────────────────────────────────┐
│ Browser │────▶│ sofarr Server
│ (User) │◀────│ Auth · Dashboard · History · Webhooks
└─────────────┘
SSE push ◀───────│ Poller (smart: skips when webhooks active)
│ Cache · PDCA Download Registry · PALDRA
└───┬─────────────────────────┬────────────────┘
│ polls (background) │ receives webhooks
┌──────────────────────────┐ ┌─────────▼───────────────────┐
│ Download Clients │ │ *arr Services │
│ SABnzbd (Usenet) │ │ Sonarr (TV management) │
│ qBittorrent (Torrent) │◀───│ Radarr (Movie management) │
│ Transmission (Torrent) │ └─────────────────────────────┘
│ rTorrent (Torrent) │
└──────────────────────────┘
Emby / Jellyfin
(User authentication)
┌─────────────┐ ┌──────────────┐ ┌─────────────────────────────┐
│ Browser │────▶│ sofarr │────▶│ SABnzbd (Usenet downloads)
│ (User) │◀────│ Server │ │ qBittorrent (Torrents)
└─────────────┘ └──────────────┘ Sonarr (TV management)
│ │ Radarr (Movie management)
│ │ Emby (User authentication)
▼ └─────────────────────────────┘
┌──────────────┐
│ Dashboard
│ Aggregator │
└──────────────┘
```
**Three pluggable layers power sofarr:**
| Layer | Name | What it does |
|-------|------|--------------|
| Download clients | **PDCA** (Pluggable Download Client Architecture) | Normalised interface for SABnzbd, qBittorrent, Transmission, rTorrent; easy to add new clients |
| *arr data retrieval | **PALDRA** (Pluggable *Arr Library Data Retrieval Architecture) | Unified fetch layer for Sonarr/Radarr queue, history, and tags across multiple instances |
| Real-time push | **Webhook receiver** | Sonarr/Radarr POST events to sofarr; cache updated immediately; SSE pushes to browser in < 1s |
### Webhooks
When webhooks are configured, sofarr receives instant push notifications from Sonarr and Radarr whenever a download is grabbed, imported, failed, or renamed. The dashboard updates in under a second — no polling delay.
**Quick setup:**
1. Set `SOFARR_WEBHOOK_SECRET` and `SOFARR_BASE_URL` in your `.env`
2. Open the sofarr dashboard → **Webhooks Configuration** panel
3. Click **Enable** next to each Sonarr/Radarr instance
4. sofarr auto-configures the notification connection inside each *arr service
**Smart polling fallback:** Once webhooks are active, the background poller automatically skips queue/history API calls for those instances. If no webhook events arrive for `WEBHOOK_FALLBACK_TIMEOUT` minutes (default: 10), the poller resumes a full poll automatically.
**Webhook endpoints** (no user authentication required — protected by `X-Sofarr-Webhook-Secret`):
- `POST /api/webhook/sonarr` — receives Sonarr events
- `POST /api/webhook/radarr` — receives Radarr events
### The Matching Process
1. **User Authentication**: Login via Emby credentials
2. **Tag-Based Matching**:
2. **Tag-Based Matching**:
- Your media in Sonarr/Radarr is tagged with your username (e.g., "gordon")
- sofarr checks Sonarr/Radarr activity to find items tagged with your name
- Downloads (from SABnzbd, qBittorrent, Transmission, or rTorrent) are matched by title to that activity
- Downloads (from SABnzbd/qBittorrent) are matched by title to that activity
- Only your downloads appear on your dashboard
### Multi-Instance Support
@@ -88,8 +51,8 @@ SONARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
## Prerequisites
- **Docker** (recommended), or Node.js (v22+) for manual installation
- At least one download client: SABnzbd, qBittorrent, Transmission, or rTorrent
- **Docker** (recommended), or Node.js (v12+) for manual installation
- At least one of: SABnzbd or qBittorrent
- Sonarr (optional, for TV tracking)
- Radarr (optional, for movie tracking)
- Emby (for user authentication)
@@ -144,10 +107,7 @@ docker run -d \
-e RADARR_INSTANCES='[{"name":"main","url":"http://radarr:7878","apiKey":"your-key"}]' \
-e SABNZBD_INSTANCES='[{"name":"main","url":"http://sabnzbd:8080","apiKey":"your-key"}]' \
-e QBITTORRENT_INSTANCES='[{"name":"main","url":"http://qbit:8080","username":"admin","password":"pass"}]' \
-e TRANSMISSION_INSTANCES='[{"name":"main","url":"http://transmission:9091","username":"admin","password":"pass"}]' \
-e RTORRENT_INSTANCES='[{"name":"main","url":"http://rtorrent:8080/RPC2","username":"rtorrent","password":"rtorrent"}]' \
-e LOG_LEVEL=info \
-e POLL_INTERVAL=5000 \
docker.i3omb.com/sofarr:latest
```
@@ -169,10 +129,7 @@ services:
- RADARR_INSTANCES=[{"name":"main","url":"http://radarr:7878","apiKey":"your-key"}]
- SABNZBD_INSTANCES=[{"name":"main","url":"http://sabnzbd:8080","apiKey":"your-key"}]
- QBITTORRENT_INSTANCES=[{"name":"main","url":"http://qbit:8080","username":"admin","password":"pass"}]
- TRANSMISSION_INSTANCES=[{"name":"main","url":"http://transmission:9091","username":"admin","password":"pass"}]
- RTORRENT_INSTANCES=[{"name":"main","url":"http://rtorrent:8080/RPC2","username":"rtorrent","password":"rtorrent"}]
- LOG_LEVEL=info
- POLL_INTERVAL=5000
```
> **Tip:** You can also use a combination — mount a `.env` file for base config, and override specific values with `-e` flags. Environment variables always take precedence.
@@ -182,8 +139,8 @@ services:
| Tag | Description |
|-----|-------------|
| `latest` | Latest stable release |
| `1.0` | Latest patch for the 1.0.x release line |
| `1.0.0` | Specific version |
| `0.1` | Latest patch for the 0.1.x release line |
| `0.1.0` | Specific version |
### Updating
@@ -224,34 +181,8 @@ Open `http://localhost:3001` in your browser
```bash
PORT=3001 # Server port
LOG_LEVEL=info # Logging: debug, info, warn, error, silent
POLL_INTERVAL=5000 # Background polling interval in ms (default: 5000)
# Set to 0 or "off" to disable (on-demand mode)
```
### Webhooks & Smart Polling
```bash
# Required for webhook endpoints to accept events
SOFARR_WEBHOOK_SECRET=your-secret # Shared secret (generate: openssl rand -hex 32)
SOFARR_BASE_URL=https://sofarr.example.com # Public URL used by one-click setup
# Optional tuning
WEBHOOK_FALLBACK_TIMEOUT=10 # Minutes without a webhook before forcing a full poll (default: 10)
WEBHOOK_POLL_INTERVAL_MULTIPLIER=3 # Internal multiplier used by the skip logic (default: 3)
```
### Download Clients (PDCA)
sofarr uses a **Pluggable Download Client Architecture (PDCA)** that provides a unified interface for all download clients. This enables consistent data normalization, easy addition of new client types, and centralized configuration management.
**Supported Download Clients:**
| Client | Protocol | Auth Method | Notes |
|--------|----------|-------------|-------|
| SABnzbd | REST API | API Key | Usenet downloads |
| qBittorrent | Sync API | Username/Password | BitTorrent with incremental updates |
| Transmission | JSON-RPC | Username/Password | BitTorrent with session management |
| rTorrent | XML-RPC | HTTP Basic Auth | BitTorrent, requires the full RPC endpoint in the url field (e.g. /RPC2 or /xmlrpc for whatbox.ca). No path is automatically appended. |
### Service Instances (JSON Array Format)
All services support multi-instance configuration via single-line JSON arrays:
@@ -263,21 +194,10 @@ SABNZBD_INSTANCES=[{"name":"primary","url":"https://sabnzbd.example.com","apiKey
# qBittorrent Instances (uses username/password, not API key)
QBITTORRENT_INSTANCES=[{"name":"main","url":"https://qbittorrent.example.com","username":"admin","password":"secret"}]
# Transmission Instances (uses username/password)
TRANSMISSION_INSTANCES=[{"name":"main","url":"http://transmission:9091/transmission/rpc","username":"admin","password":"pass"}]
# rTorrent Instances (uses username/password, URL must include full RPC endpoint)
# Standard installs use /RPC2. Some providers like whatbox.ca use /xmlrpc.
# No path is automatically appended - always include the full RPC endpoint.
RTORRENT_INSTANCES=[{"name":"main","url":"http://rtorrent:8080/RPC2","username":"rtorrent","password":"rtorrent"}]
# For whatbox.ca (example):
# RTORRENT_INSTANCES=[{"name":"whatbox","url":"https://user.whatbox.ca/xmlrpc","username":"user","password":"pass"}]
# Sonarr Instances
SONARR_INSTANCES=[{"name":"hd","url":"https://sonarr.example.com","apiKey":"your-api-key"}]
# Radarr Instances
# Radarr Instances
RADARR_INSTANCES=[{"name":"movies","url":"https://radarr.example.com","apiKey":"your-api-key"}]
# Emby (single instance for authentication)
@@ -291,18 +211,6 @@ If you only have one instance, you can use the legacy format:
```bash
SABNZBD_URL=https://sabnzbd.example.com
SABNZBD_API_KEY=your-api-key
QBITTORRENT_URL=https://qbittorrent.example.com
QBITTORRENT_USERNAME=admin
QBITTORRENT_PASSWORD=secret
TRANSMISSION_URL=http://transmission:9091/transmission/rpc
TRANSMISSION_USERNAME=admin
TRANSMISSION_PASSWORD=pass
RTORRENT_URL=http://rtorrent:8080/RPC2
RTORRENT_USERNAME=rtorrent
RTORRENT_PASSWORD=rtorrent
```
## Setting Up User Tags
@@ -323,22 +231,9 @@ To see your downloads, you need to tag your media in Sonarr/Radarr:
## Features in Detail
### Background Polling
sofarr polls all configured services in the background and caches the results. Dashboard requests read from cache, making them near-instant regardless of how many services you have.
| Setting | Behaviour |
|---------|----------|
| `POLL_INTERVAL=5000` (default) | Background poller fetches every 5s. Dashboard reads from cache instantly. |
| `POLL_INTERVAL=10000` | Poll every 10 seconds. |
| `POLL_INTERVAL=0` / `off` / `disabled` | No background polling. Data fetched on-demand when a user opens the dashboard, then cached for 30 seconds. |
**On-demand mode** is useful for low-resource setups. When one user's browser opens the SSE stream it triggers a fresh poll; the fetched data is cached and served to all other connected clients until the next poll.
### Real-Time Updates
- **Server-Sent Events (SSE)** — the server pushes updates to the browser immediately after each poll cycle. No client-side timer; no wasted requests.
- Auto-refresh every 5 seconds (configurable: 1s, 5s, 10s, or off)
- In-place DOM updates for smooth UI (no flickering)
- Browser reconnects automatically on network interruption
### Download Information Displayed
- **Progress bar** with visual completion percentage
@@ -351,46 +246,23 @@ sofarr polls all configured services in the background and caches the results. D
### For qBittorrent Downloads
- **Seeds** - Number of seeders
- **Peers** - Number of peers
- **Availability** - Percentage available in swarm (shown in red when below 100%)
- **Availability** - Percentage available in swarm
## API Endpoints
### Authentication
- `POST /api/auth/login` Login with Emby credentials
- `POST /api/auth/logout` Logout and revoke session
- `GET /api/auth/me` — Check current session
- `GET /api/csrf` — Fetch a CSRF token
- `POST /api/auth/login` - Login with Emby credentials
- `POST /api/auth/logout` - Logout and clear session
### Dashboard
- `GET /api/dashboard/stream`**SSE stream**: pushes `{ user, isAdmin, downloads }` on every poll cycle
- `GET /api/dashboard/user-downloads` — Single-request download fetch (no streaming)
- `GET /api/dashboard/user-summary` — Per-user download counts (admin)
- `GET /api/dashboard/status` — Server / polling / cache status (admin)
- `GET /api/dashboard/cover-art` — Proxied cover art image
- `POST /api/dashboard/blocklist-search` — Blocklist a release and trigger a new search (admin or non-admin with eligibility: import issues OR torrent >1h old AND availability<100%)
### History
- `GET /api/history/recent` — Recently completed downloads from Sonarr/Radarr history
### Webhook Receiver (no user auth — protected by `X-Sofarr-Webhook-Secret`)
- `POST /api/webhook/sonarr` — receive Sonarr webhook events
- `POST /api/webhook/radarr` — receive Radarr webhook events
### Webhook Management (requires auth + CSRF)
- `GET /api/sonarr/api/v3/notification` — list Sonarr notification connections
- `POST /api/sonarr/api/v3/notification` — create/update Sonarr notification connection
- `GET /api/radarr/api/v3/notification` — list Radarr notification connections
- `POST /api/radarr/api/v3/notification` — create/update Radarr notification connection
- `POST /api/sonarr/webhook/enable` — one-click enable Sofarr webhook in Sonarr
- `POST /api/radarr/webhook/enable` — one-click enable Sofarr webhook in Radarr
- `POST /api/sonarr/webhook/test` — trigger a Sonarr test event
- `POST /api/radarr/webhook/test` — trigger a Radarr test event
- `GET /api/dashboard/downloads` - Get all downloads for authenticated user
### Service APIs (proxy to your services)
- `GET /api/sabnzbd/*` SABnzbd API proxy
- `GET /api/sonarr/*` — Sonarr API proxy
- `GET /api/radarr/*` — Radarr API proxy
- `GET /api/emby/*` — Emby API proxy
- `GET /api/sabnzbd/*` - SABnzbd API proxy
- `GET /api/qbittorrent/*` - qBittorrent API proxy
- `GET /api/sonarr/*` - Sonarr API proxy
- `GET /api/radarr/*` - Radarr API proxy
- `GET /api/emby/*` - Emby API proxy
## Logging Levels
@@ -420,17 +292,6 @@ Logs are written to both console and `server.log` file.
- Check qBittorrent Web UI is enabled
- Verify the URL includes the full path (e.g., `https://qb.example.com`)
## Testing
```bash
npm test # run all tests once
npm run test:watch # watch mode
npm run test:coverage # with V8 coverage report (outputs to coverage/)
npm run test:ui # interactive Vitest UI
```
290 tests across 18 test files covering auth middleware, CSRF protection, secret sanitization, config parsing, token store, qBittorrent utilities, history deduplication/classification, download client architecture, webhook endpoint security, input validation, replay protection, and webhook metrics integration. See [`tests/README.md`](tests/README.md) for design decisions and coverage targets.
## Development
```bash
@@ -448,4 +309,3 @@ MIT
---
*sofarr: See what has downloaded "so far" from the comfort of your "sofa"*
-171
View File
@@ -1,171 +0,0 @@
# Security Policy & Hardening Guide
## Supported Versions
| Version | Supported |
|---------|-----------|
| 1.4.x | ✅ Yes |
| 1.3.x | ✅ Yes |
| 1.2.x | ✅ Yes |
| 1.1.x | ❌ No |
| 1.0.x | ❌ No |
| < 1.0 | ❌ No |
## Reporting a Vulnerability
Please **do not** open a public issue for security vulnerabilities.
Email: gordon@i3omb.com — expect acknowledgement within 48 hours.
---
## Threat Model
sofarr is a personal dashboard intended for a small trusted group (household/team).
It proxies requests to *arr stack services using stored API keys and authenticates
users via Emby. The primary threat surface when exposed to the public internet:
| Threat | Mitigations |
|--------|-------------|
| Credential brute-force | Rate limiting (10 fails/15 min per IP), account lockout window |
| Session hijacking | HMAC-signed cookies, `httpOnly`, `secure`, `sameSite=strict`, short TTL |
| CSRF | Double-submit cookie pattern (`X-CSRF-Token` header required on all mutations) |
| API key leakage via errors | `sanitizeError()` redacts keys/tokens from all error responses and logs |
| Token theft after logout | Server-side token store; Emby token revoked on logout |
| XSS → token theft | `httpOnly` cookies; CSP with per-request nonce blocks inline injection |
| Clickjacking | `X-Frame-Options: DENY` + CSP `frame-ancestors 'none'` |
| Info disclosure via headers | Helmet v7 removes `X-Powered-By`, sets `noSniff`, `xssFilter`, etc. |
| Privilege escalation (container) | Non-root user (UID 1000), `no-new-privileges`, all caps dropped |
| Unbounded log growth | Size-based rotation: 10 MB cap, 3 rotated files kept |
| Dependency vulnerabilities | `npm audit --audit-level=high` in CI on every push |
| Unauthorized webhook injection | `SOFARR_WEBHOOK_SECRET` required on `X-Sofarr-Webhook-Secret` header; 401 on mismatch |
| Webhook payload injection | `validatePayload()` allowlists 18 known event types; rejects non-object bodies and overlong fields |
| Webhook replay attacks | `isReplay()` tracks `(eventType, instanceName, date)` tuples for 5 minutes; duplicate events return `200 { duplicate: true }` without cache mutation |
| Webhook flood / DoS | Dedicated rate limiter: 60 requests/min per IP on `/api/webhook/*` |
---
## Production Deployment Checklist
### Required
- [ ] `COOKIE_SECRET` set to a random 32-byte hex string (`openssl rand -hex 32`)
- [ ] `NODE_ENV=production`
- [ ] `TRUST_PROXY=1` set if behind a reverse proxy
- [ ] sofarr bound to `127.0.0.1` only (not `0.0.0.0`) — expose via proxy
- [ ] HTTPS enforced by the reverse proxy with a valid certificate
- [ ] Firewall rules: only 443/80 open externally; 3001 not directly exposed
### Webhook-Specific (if using webhook integration)
- [ ] `SOFARR_WEBHOOK_SECRET` set to a random 32-byte hex string (`openssl rand -hex 32`)
- [ ] `SOFARR_BASE_URL` set to the public HTTPS URL of sofarr (used by one-click setup)
- [ ] Secret stored only in `.env` or Docker secret — never committed to source control
- [ ] Rotate `SOFARR_WEBHOOK_SECRET` if you suspect it has been leaked; re-enable webhooks via the UI
- [ ] Verify Sonarr/Radarr send the exact secret value in the `X-Sofarr-Webhook-Secret` header
- [ ] Review webhook logs (`[Webhook] WARNING`) for repeated auth failures which may indicate probing
### Recommended
- [ ] Reverse proxy: Nginx, Caddy, or Traefik with TLS termination
- [ ] Set `Strict-Transport-Security` at proxy level (sofarr also sends HSTS)
- [ ] `DATA_DIR` on a named Docker volume (not bind-mounted to sensitive host path)
- [ ] Rotate `COOKIE_SECRET` periodically (causes all users to re-login)
- [ ] Enable Docker's `--read-only` flag (already in `docker-compose.yaml`)
- [ ] Monitor `/health` endpoint with an uptime checker
### Docker Secrets (alternative to env vars)
For production environments that support Docker secrets, you can mount secret
files and reference them:
```yaml
secrets:
cookie_secret:
file: ./secrets/cookie_secret.txt
emby_api_key:
file: ./secrets/emby_api_key.txt
services:
sofarr:
secrets:
- cookie_secret
- emby_api_key
environment:
- COOKIE_SECRET_FILE=/run/secrets/cookie_secret
- EMBY_API_KEY_FILE=/run/secrets/emby_api_key
```
> Since v1.2.0, sofarr natively supports the `_FILE` pattern.
> Set `COOKIE_SECRET_FILE=/run/secrets/cookie_secret` and sofarr will
> read the secret value from that file at startup. See `docker-compose.yaml`
> for a complete example.
---
## Reverse Proxy Example (Caddy)
```caddy
sofarr.example.com {
reverse_proxy localhost:3001
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Robots-Tag "noindex, nofollow"
}
}
```
## Reverse Proxy Example (Nginx)
```nginx
server {
listen 443 ssl;
server_name sofarr.example.com;
ssl_certificate /etc/letsencrypt/live/sofarr.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/sofarr.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Required for SSE (Server-Sent Events) — disable response buffering
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
}
}
```
---
## Security Headers (emitted by sofarr)
| Header | Value |
|--------|-------|
| `Content-Security-Policy` | `default-src 'self'; script-src 'self' 'nonce-…'; style-src 'self' 'nonce-…'; style-src-attr 'unsafe-inline'; img-src 'self' data: blob:; object-src 'none'; frame-ancestors 'none'` |
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains; preload` (production only) |
| `X-Content-Type-Options` | `nosniff` |
| `X-Frame-Options` | `DENY` |
| `Referrer-Policy` | `strict-origin-when-cross-origin` |
| `Permissions-Policy` | `camera=(), microphone=(), geolocation=(), payment=(), usb=()` |
---
## Rate Limits
| Endpoint | Limit |
|----------|-------|
| `POST /api/auth/login` | 10 failed attempts per 15 min per IP |
| All `/api/*` routes | 300 requests per 15 min per IP |
| `POST /api/webhook/*` | 60 requests per 1 min per IP (webhook-specific limiter, stricter than general) |
---
## Supply Chain
- All dependencies pinned to minor version ranges in `package.json`
- `npm audit --audit-level=high` runs in CI on every push and pull request
- `npm audit fix` should be run when vulnerabilities are reported
-6
View File
@@ -1,6 +0,0 @@
# Ignore all cert/key files EXCEPT the bundled snakeoil development defaults.
# Never commit real TLS certificates or private keys to version control.
*
!.gitignore
!snakeoil.crt
!snakeoil.key
-22
View File
@@ -1,22 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIDoTCCAomgAwIBAgIUPgupZzf+zgMp4ylvf1NBRIWNpeUwDQYJKoZIhvcNAQEL
BQAwUjELMAkGA1UEBhMCR0IxDjAMBgNVBAgMBUxvY2FsMQ4wDAYDVQQHDAVMb2Nh
bDEPMA0GA1UECgwGc29mYXJyMRIwEAYDVQQDDAlsb2NhbGhvc3QwHhcNMjYwNTE3
MDk0NDQ4WhcNMzYwNTE0MDk0NDQ4WjBSMQswCQYDVQQGEwJHQjEOMAwGA1UECAwF
TG9jYWwxDjAMBgNVBAcMBUxvY2FsMQ8wDQYDVQQKDAZzb2ZhcnIxEjAQBgNVBAMM
CWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL5Y05DF
9U3k27SGByJqdbKThRRabX0hsK0zckkbXoaj0U4odZatUicAqkm6yWzpqQyZM6qH
XX68LXqJk8r2VIdTTtKe5QU1WUAsW6KD0c514YC1VZ3a9ivQ2/tfdQsm8YxM/ECq
e2XFklfvE72HkHF4fM9LCMS/LeazazF0ogNTkyE27g9Ry/ofR2P4MymLtyBbQ1NA
B1zYRIhJ5HqFRMszBKhWi1zRgUQQNBuYA5wtxnXA9QNSz6ObtdWStfJ/C1Kuitpe
OVrB/TKCp1OKBpZTd4mBKlvdJWos9eZPzP4vLnsReT4pHBx6J2Jd1dC97/MAXLYP
mIXP+04zK5sWRUsCAwEAAaNvMG0wHQYDVR0OBBYEFCpYKb3zMgv4qThe8ukFrVIl
lhD3MB8GA1UdIwQYMBaAFCpYKb3zMgv4qThe8ukFrVIllhD3MA8GA1UdEwEB/wQF
MAMBAf8wGgYDVR0RBBMwEYcEfwAAAYIJbG9jYWxob3N0MA0GCSqGSIb3DQEBCwUA
A4IBAQBQ5Jg0pn4XW/560laNl7XAoGuMwrIy5j6zDlIgFE8DWAb0NGNyu/FkCVIZ
ruLNktq+w+kTeneAuYW3CGyac2HZo9T60VtQNL/k17hrDw1F6kMpAAYm6aiyFtE9
Stw07PMvpvjxKvetPLOQsfk0/hh0Nh2PiVVdqvVE/gGoraboHEfH+eGf/Arzg7s4
CzZTEz0OJP5i7VkZAvFygShPx/gHY77ojeHPl2LN3KI7s43TrjYZjxbUDwWDpGp0
BIsDFP+9NAvA5I74biChfEEopmBczVbQRtqBxBX1JTa2aT5w/ifUNyKVKIGp49Q8
o59gDmbCXhypom7OsyxBLZgyVWU1
-----END CERTIFICATE-----
-28
View File
@@ -1,28 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC+WNOQxfVN5Nu0
hgcianWyk4UUWm19IbCtM3JJG16Go9FOKHWWrVInAKpJusls6akMmTOqh11+vC16
iZPK9lSHU07SnuUFNVlALFuig9HOdeGAtVWd2vYr0Nv7X3ULJvGMTPxAqntlxZJX
7xO9h5BxeHzPSwjEvy3ms2sxdKIDU5MhNu4PUcv6H0dj+DMpi7cgW0NTQAdc2ESI
SeR6hUTLMwSoVotc0YFEEDQbmAOcLcZ1wPUDUs+jm7XVkrXyfwtSroraXjlawf0y
gqdTigaWU3eJgSpb3SVqLPXmT8z+Ly57EXk+KRwceidiXdXQve/zAFy2D5iFz/tO
MyubFkVLAgMBAAECggEAEk0RCyNCJBSdqSKWLtFq8o0rdBsDpugyOymuOQHOjOxu
oQo6NnWaNF5kgQVAyfd0EpGFfavyw2iNVaUPo1zoU9ogwuOWSnHOj64UIcp0+PN6
VHknohWqdukBLyiGfnJM/0ieaKTbi2i7dGsfDA+rxbUoO6s2GYPC6O9inlUqVJGU
fBXKTbCneW1+hJQFcOKPW2+qwiUxbudG9neNTYFOm9a7Bfa6MLJ22mkfYVrHYDzo
gESo8sHXbEtvemka9jN0N/GoXgUi7iFXbqslPUoakCF7sgG0cXuUM9duSt67ynLj
j7Ix+QiKAZgvSO/83b7vK74Xmd6XFUif41VMZ2iEGQKBgQDqp/ZSCAakarRpBRN4
psKuhaYiBKurb5GezTOSfEGWxmng2s0bHcx74alKKNN8Tu2mNx6yafQdRNQdM+fG
dn8JnQUGXYpGwQbLHZ/aO0M64WBxfQku4JNGh+VZ3ZyUC+So28vzCYqx8qwJi94L
2sF8787hzHO1INzVaeXzQ89pEwKBgQDPqRzsJsP+zZmA4x16CtoWY7Z1d4z0GTkA
erlXNinu76AIvra6aUld/65ymMyNIIpLZoci55ZGxBY7g8U29e10iT+xmxAgq3VT
Tn438MbDXEgA+HvdRXCFPvNQQk0ZDegazdhTdtuhj+IHcm4M94zfVM3MSJOr0hJf
JGaVIGEx6QKBgDZLftckPEU221+haQv1qf4vtm0Qn5gfTJZt7Izsa1CzwDPi7Kpl
jrbrU/xwzd5pdNuMzXGCypUrI9lN9Ucai/JxfoQmiKQubZ/5zs7z/25UT7hysflC
xVEAiLTubhhjWBkqIlqtzoW2HNBoqIwdpb9+zWO5ptw2KmLHCgnrmsY5AoGBAKOt
YCaix4lm9L8qRGmVdCCBp6ce+/LKjqtaEAw1nQe/yBwcdlqn8jQs+4tH9LKoG1kj
DxDsCP7uP7fZPPD9FpTsOU/8MNIPUwK+s63UElaZvgdF1BusR+w+mfmAyNQeqfu2
k/P1k1fc2QOVpjiCRn8hkLSb4AlmIyTqxBB23SVBAoGATMtuk1r+SS9SDJW73CI1
jLkJkPGrBvX0dFZGtedpwLVhUTDnqKIsy2F1iGDRGIAy5IAiLEFx44qQrhUau7zR
/2avY0Ua9To9LW0k/matzJUKvXxYm59jh/GDp6LFU89hsL2zSd6z0Aknvh1SryCb
OSbN8wfCz53+7qea4NQEB4E=
-----END PRIVATE KEY-----
-193
View File
@@ -304,196 +304,3 @@ body {
grid-template-columns: 1fr;
}
}
/* Webhooks Section Styles */
.webhooks-section {
background: white;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
overflow: hidden;
}
.webhooks-header {
padding: 20px 30px;
background: #f8f9fa;
border-bottom: 2px solid #e0e0e0;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
transition: background 0.3s;
}
.webhooks-header:hover {
background: #f0f1f2;
}
.webhooks-header h2 {
color: #333;
font-size: 1.3rem;
margin: 0;
}
.webhooks-toggle {
font-size: 1.2rem;
color: #666;
transition: transform 0.3s;
}
.webhooks-toggle.expanded {
transform: rotate(180deg);
}
.webhooks-content {
padding: 20px 30px;
}
.webhook-instance {
padding: 20px 0;
border-bottom: 1px solid #e0e0e0;
}
.webhook-instance:last-child {
border-bottom: none;
}
.webhook-instance h3 {
color: #333;
font-size: 1.1rem;
margin-bottom: 15px;
}
.webhook-status {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 15px;
}
.status-indicator {
font-size: 1rem;
font-weight: 500;
padding: 5px 15px;
border-radius: 20px;
}
.status-indicator.enabled {
background: #e8f5e9;
color: #4caf50;
}
.status-indicator.disabled {
background: #f5f5f5;
color: #999;
}
.enable-webhook-btn {
padding: 8px 16px;
background: #667eea;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 0.95rem;
transition: background 0.3s;
}
.enable-webhook-btn:hover {
background: #5568d3;
}
.enable-webhook-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.test-webhook-btn {
padding: 8px 16px;
background: #f093fb;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 0.95rem;
transition: background 0.3s;
}
.test-webhook-btn:hover {
background: #d97ed8;
}
.test-webhook-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.webhook-triggers {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
padding-top: 15px;
border-top: 1px solid #e0e0e0;
}
.trigger-item {
display: flex;
justify-content: space-between;
align-items: center;
}
.trigger-label {
color: #666;
font-size: 0.9rem;
}
.trigger-value {
font-weight: 500;
font-size: 1.1rem;
}
.trigger-value.active {
color: #4caf50;
}
.trigger-value.inactive {
color: #999;
}
.webhook-stats {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #e0e0e0;
}
.webhook-stats-title {
color: #999;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
margin-bottom: 10px;
}
.webhook-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px;
}
.webhook-stat {
display: flex;
flex-direction: column;
gap: 3px;
}
.webhook-stat-label {
color: #999;
font-size: 0.8rem;
}
.webhook-stat-value {
color: #333;
font-size: 0.95rem;
font-weight: 500;
}
-296
View File
@@ -1,4 +1,3 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { useState, useEffect } from 'react';
import axios from 'axios';
import './App.css';
@@ -10,15 +9,9 @@ function App() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [sessions, setSessions] = useState([]);
const [webhookSectionExpanded, setWebhookSectionExpanded] = useState(false);
const [sonarrWebhook, setSonarrWebhook] = useState({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null });
const [radarrWebhook, setRadarrWebhook] = useState({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null });
const [webhookMetrics, setWebhookMetrics] = useState(null);
const [webhookLoading, setWebhookLoading] = useState(false);
useEffect(() => {
fetchSessions();
fetchWebhookStatus();
}, []);
const fetchSessions = async () => {
@@ -73,153 +66,6 @@ function App() {
return new Date(dateString).toLocaleString();
};
const formatTimeAgo = (timestamp) => {
if (!timestamp) return 'Never';
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
};
const fetchWebhookMetrics = async () => {
try {
const response = await axios.get('/api/dashboard/webhook-metrics');
setWebhookMetrics(response.data);
return response.data;
} catch (err) {
// Not fatal — stats just won't display
return null;
}
};
const fetchWebhookStatus = async () => {
try {
// Fetch metrics in parallel with notification status
const metricsPromise = fetchWebhookMetrics();
// Fetch Sonarr notifications
let sonarrEnabled = false;
let sonarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false };
try {
const sonarrResponse = await axios.get('/api/sonarr/notifications');
const sonarrSofarr = sonarrResponse.data.find(n => n.name === 'Sofarr');
sonarrEnabled = !!sonarrSofarr;
if (sonarrSofarr) {
sonarrTriggers = {
onGrab: sonarrSofarr.onGrab,
onDownload: sonarrSofarr.onDownload,
onImport: sonarrSofarr.onImport,
onUpgrade: sonarrSofarr.onUpgrade
};
}
} catch (err) {
// Sonarr not configured or not accessible
}
// Fetch Radarr notifications
let radarrEnabled = false;
let radarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false };
try {
const radarrResponse = await axios.get('/api/radarr/notifications');
const radarrSofarr = radarrResponse.data.find(n => n.name === 'Sofarr');
radarrEnabled = !!radarrSofarr;
if (radarrSofarr) {
radarrTriggers = {
onGrab: radarrSofarr.onGrab,
onDownload: radarrSofarr.onDownload,
onImport: radarrSofarr.onImport,
onUpgrade: radarrSofarr.onUpgrade
};
}
} catch (err) {
// Radarr not configured or not accessible
}
const metrics = await metricsPromise;
// Attach per-instance stats from global metrics.
// The instances object is keyed by instance URL; we pick the first
// sonarr/radarr entry by matching env-configured URLs.
const instanceEntries = metrics ? Object.entries(metrics.instances || {}) : [];
const sonarrStats = instanceEntries.find(([url]) => url.includes('sonarr'))?.[1] || null;
const radarrStats = instanceEntries.find(([url]) => url.includes('radarr'))?.[1] || null;
setSonarrWebhook({ enabled: sonarrEnabled, triggers: sonarrTriggers, stats: sonarrStats });
setRadarrWebhook({ enabled: radarrEnabled, triggers: radarrTriggers, stats: radarrStats });
} catch (err) {
console.error('Failed to fetch webhook status:', err);
}
};
const enableSonarrWebhook = async () => {
setWebhookLoading(true);
try {
await axios.post('/api/sonarr/notifications/sofarr-webhook');
await fetchWebhookStatus();
} catch (err) {
console.error('Failed to enable Sonarr webhook:', err);
alert('Failed to enable Sonarr webhook. Check console for details.');
} finally {
setWebhookLoading(false);
}
};
const enableRadarrWebhook = async () => {
setWebhookLoading(true);
try {
await axios.post('/api/radarr/notifications/sofarr-webhook');
await fetchWebhookStatus();
} catch (err) {
console.error('Failed to enable Radarr webhook:', err);
alert('Failed to enable Radarr webhook. Check console for details.');
} finally {
setWebhookLoading(false);
}
};
const testSonarrWebhook = async () => {
setWebhookLoading(true);
try {
const sonarrResponse = await axios.get('/api/sonarr/notifications');
const sonarrSofarr = sonarrResponse.data.find(n => n.name === 'Sofarr');
if (sonarrSofarr) {
await axios.post('/api/sonarr/notifications/test', { id: sonarrSofarr.id });
await fetchWebhookStatus();
alert('Sonarr webhook test sent successfully!');
} else {
alert('Sofarr webhook not configured for Sonarr.');
}
} catch (err) {
console.error('Failed to test Sonarr webhook:', err);
alert('Failed to test Sonarr webhook. Check console for details.');
} finally {
setWebhookLoading(false);
}
};
const testRadarrWebhook = async () => {
setWebhookLoading(true);
try {
const radarrResponse = await axios.get('/api/radarr/notifications');
const radarrSofarr = radarrResponse.data.find(n => n.name === 'Sofarr');
if (radarrSofarr) {
await axios.post('/api/radarr/notifications/test', { id: radarrSofarr.id });
await fetchWebhookStatus();
alert('Radarr webhook test sent successfully!');
} else {
alert('Sofarr webhook not configured for Radarr.');
}
} catch (err) {
console.error('Failed to test Radarr webhook:', err);
alert('Failed to test Radarr webhook. Check console for details.');
} finally {
setWebhookLoading(false);
}
};
return (
<div className="app">
<header className="app-header">
@@ -331,148 +177,6 @@ function App() {
</div>
)}
<div className="webhooks-section">
<div className="webhooks-header" onClick={() => setWebhookSectionExpanded(!webhookSectionExpanded)}>
<h2> Webhooks Configuration</h2>
<span className={`webhooks-toggle ${webhookSectionExpanded ? 'expanded' : ''}`}></span>
</div>
{webhookSectionExpanded && (
<div className="webhooks-content">
{webhookLoading && <div className="loading">Loading webhook status...</div>}
<div className="webhook-instance">
<h3>Sonarr</h3>
<div className="webhook-status">
<span className={`status-indicator ${sonarrWebhook.enabled ? 'enabled' : 'disabled'}`}>
{sonarrWebhook.enabled ? '● Enabled' : '○ Disabled'}
</span>
{!sonarrWebhook.enabled && (
<button onClick={enableSonarrWebhook} className="enable-webhook-btn" disabled={webhookLoading}>
Enable Sofarr Webhooks
</button>
)}
{sonarrWebhook.enabled && (
<button onClick={testSonarrWebhook} className="test-webhook-btn" disabled={webhookLoading}>
Test
</button>
)}
</div>
{sonarrWebhook.enabled && (
<div className="webhook-triggers">
<div className="trigger-item">
<span className="trigger-label">On Grab</span>
<span className={`trigger-value ${sonarrWebhook.triggers.onGrab ? 'active' : 'inactive'}`}>
{sonarrWebhook.triggers.onGrab ? '✓' : '✗'}
</span>
</div>
<div className="trigger-item">
<span className="trigger-label">On Download</span>
<span className={`trigger-value ${sonarrWebhook.triggers.onDownload ? 'active' : 'inactive'}`}>
{sonarrWebhook.triggers.onDownload ? '✓' : '✗'}
</span>
</div>
<div className="trigger-item">
<span className="trigger-label">On Import</span>
<span className={`trigger-value ${sonarrWebhook.triggers.onImport ? 'active' : 'inactive'}`}>
{sonarrWebhook.triggers.onImport ? '✓' : '✗'}
</span>
</div>
<div className="trigger-item">
<span className="trigger-label">On Upgrade</span>
<span className={`trigger-value ${sonarrWebhook.triggers.onUpgrade ? 'active' : 'inactive'}`}>
{sonarrWebhook.triggers.onUpgrade ? '✓' : '✗'}
</span>
</div>
</div>
)}
{sonarrWebhook.stats && (
<div className="webhook-stats">
<div className="webhook-stats-title">Statistics</div>
<div className="webhook-stats-grid">
<div className="webhook-stat">
<span className="webhook-stat-label">Events Received</span>
<span className="webhook-stat-value">{sonarrWebhook.stats.eventsReceived ?? 0}</span>
</div>
<div className="webhook-stat">
<span className="webhook-stat-label">Polls Skipped</span>
<span className="webhook-stat-value">{sonarrWebhook.stats.pollsSkipped ?? 0}</span>
</div>
<div className="webhook-stat">
<span className="webhook-stat-label">Last Event</span>
<span className="webhook-stat-value">{formatTimeAgo(sonarrWebhook.stats.lastWebhookTimestamp)}</span>
</div>
</div>
</div>
)}
</div>
<div className="webhook-instance">
<h3>Radarr</h3>
<div className="webhook-status">
<span className={`status-indicator ${radarrWebhook.enabled ? 'enabled' : 'disabled'}`}>
{radarrWebhook.enabled ? '● Enabled' : '○ Disabled'}
</span>
{!radarrWebhook.enabled && (
<button onClick={enableRadarrWebhook} className="enable-webhook-btn" disabled={webhookLoading}>
Enable Sofarr Webhooks
</button>
)}
{radarrWebhook.enabled && (
<button onClick={testRadarrWebhook} className="test-webhook-btn" disabled={webhookLoading}>
Test
</button>
)}
</div>
{radarrWebhook.enabled && (
<div className="webhook-triggers">
<div className="trigger-item">
<span className="trigger-label">On Grab</span>
<span className={`trigger-value ${radarrWebhook.triggers.onGrab ? 'active' : 'inactive'}`}>
{radarrWebhook.triggers.onGrab ? '✓' : '✗'}
</span>
</div>
<div className="trigger-item">
<span className="trigger-label">On Download</span>
<span className={`trigger-value ${radarrWebhook.triggers.onDownload ? 'active' : 'inactive'}`}>
{radarrWebhook.triggers.onDownload ? '✓' : '✗'}
</span>
</div>
<div className="trigger-item">
<span className="trigger-label">On Import</span>
<span className={`trigger-value ${radarrWebhook.triggers.onImport ? 'active' : 'inactive'}`}>
{radarrWebhook.triggers.onImport ? '✓' : '✗'}
</span>
</div>
<div className="trigger-item">
<span className="trigger-label">On Upgrade</span>
<span className={`trigger-value ${radarrWebhook.triggers.onUpgrade ? 'active' : 'inactive'}`}>
{radarrWebhook.triggers.onUpgrade ? '✓' : '✗'}
</span>
</div>
</div>
)}
{radarrWebhook.stats && (
<div className="webhook-stats">
<div className="webhook-stats-title">Statistics</div>
<div className="webhook-stats-grid">
<div className="webhook-stat">
<span className="webhook-stat-label">Events Received</span>
<span className="webhook-stat-value">{radarrWebhook.stats.eventsReceived ?? 0}</span>
</div>
<div className="webhook-stat">
<span className="webhook-stat-label">Polls Skipped</span>
<span className="webhook-stat-value">{radarrWebhook.stats.pollsSkipped ?? 0}</span>
</div>
<div className="webhook-stat">
<span className="webhook-stat-label">Last Event</span>
<span className="webhook-stat-value">{formatTimeAgo(radarrWebhook.stats.lastWebhookTimestamp)}</span>
</div>
</div>
</div>
)}
</div>
</div>
)}
</div>
<footer className="app-footer">
<p>Ensure your media is tagged with "user:username" in Sonarr/Radarr to match downloads to users.</p>
</footer>
-1
View File
@@ -1,4 +1,3 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
-1
View File
@@ -1,4 +1,3 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
+2 -63
View File
@@ -1,78 +1,17 @@
version: "3"
services:
sofarr:
image: docker.i3omb.com/sofarr:latest
container_name: sofarr
restart: unless-stopped
ports:
# Direct HTTPS (default — uses bundled snakeoil cert if TLS_CERT not set)
- "3001:3001"
# Uncomment the line below and comment out the above to bind to loopback
# only when using a reverse proxy (set TLS_ENABLED=false in that case):
# - "127.0.0.1:3001:3001"
environment:
- PORT=3001
- NODE_ENV=production
- LOG_LEVEL=info
# --- TLS ---
# Default: TLS enabled using bundled snakeoil cert (self-signed).
# Supply your own cert/key by mounting them and setting these paths:
# - TLS_CERT=/app/certs/server.crt
# - TLS_KEY=/app/certs/server.key
# Set TLS_ENABLED=false if terminating TLS at a reverse proxy instead.
# If using a reverse proxy, also set TRUST_PROXY=1 below.
# - TRUST_PROXY=1
# --- Secrets: use _FILE variants (Docker secrets) in production -------
# Option A — plain environment variables (simple, less secure):
- COOKIE_SECRET=change-me-generate-with-openssl-rand-hex-32
- EMBY_URL=https://emby.example.com
- EMBY_API_KEY=your-emby-api-key
- SONARR_INSTANCES=[{"name":"main","url":"https://sonarr.example.com","apiKey":"your-sonarr-api-key"}]
- RADARR_INSTANCES=[{"name":"main","url":"https://radarr.example.com","apiKey":"your-radarr-api-key"}]
- SABNZBD_INSTANCES=[{"name":"main","url":"https://sabnzbd.example.com","apiKey":"your-sabnzbd-api-key"}]
- QBITTORRENT_INSTANCES=[{"name":"main","url":"https://qbittorrent.example.com","username":"admin","password":"your-password"}]
# Option B — Docker secrets (_FILE pattern, recommended for production):
# Uncomment the lines below and comment out Option A above.
# Create secret files with: echo -n "value" > ./secrets/cookie_secret.txt
# - COOKIE_SECRET_FILE=/run/secrets/cookie_secret
# - EMBY_API_KEY_FILE=/run/secrets/emby_api_key
# - SONARR_API_KEY_FILE=/run/secrets/sonarr_api_key # legacy single-instance only
# - RADARR_API_KEY_FILE=/run/secrets/radarr_api_key # legacy single-instance only
# - SABNZBD_API_KEY_FILE=/run/secrets/sabnzbd_api_key # legacy single-instance only
# secrets: # uncomment when using Option B
# - cookie_secret
# - emby_api_key
volumes:
# Persistent volume for token store and log file
- sofarr-data:/app/data
# Mount your own TLS certificate and key (optional — snakeoil used if omitted)
# - /path/to/your/server.crt:/app/certs/server.crt:ro
# - /path/to/your/server.key:/app/certs/server.key:ro
# Run as the built-in non-root 'node' user (UID/GID 1000)
user: "1000:1000"
# Read-only root filesystem; only the data volume is writable
read_only: true
tmpfs:
- /tmp # Node.js needs a writable /tmp
security_opt:
- no-new-privileges:true # prevent privilege escalation via setuid binaries
cap_drop:
- ALL # drop all Linux capabilities
cap_add: [] # add back none — Node.js needs no special caps
healthcheck:
# Respects TLS_ENABLED: uses http when set to false, https otherwise.
# --no-check-certificate handles self-signed / snakeoil certs.
test: ["CMD", "/bin/sh", "-c", "[ \"${TLS_ENABLED:-true}\" = \"false\" ] && wget -qO- http://localhost:${PORT:-3001}/health || wget -qO- --no-check-certificate https://localhost:${PORT:-3001}/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
sofarr-data:
# Docker secrets definitions (uncomment and populate when using Option B above)
# secrets:
# cookie_secret:
# file: ./secrets/cookie_secret.txt
# emby_api_key:
# file: ./secrets/emby_api_key.txt
- LOG_LEVEL=info
-399
View File
@@ -1,399 +0,0 @@
# Adding a New Download Client to Sofarr
This guide explains how to add support for a new download client to Sofarr using the Pluggable Download Client Architecture (PDCA).
## Overview
The PDCA makes adding new download clients straightforward by providing a standardized interface. You only need to implement the `DownloadClient` abstract base class and register your client in the configuration system.
## Prerequisites
- Familiarity with JavaScript/Node.js
- Understanding of your target client's API
- Basic knowledge of Sofarr's architecture (see [ARCHITECTURE.md](ARCHITECTURE.md))
## Step 1: Create the Client Class
Create a new file in `server/clients/` named after your client (e.g., `DelugeClient.js`).
```javascript
// server/clients/DelugeClient.js
const DownloadClient = require('./DownloadClient');
const { logToFile } = require('../utils/logger');
class DelugeClient extends DownloadClient {
constructor(instance) {
super(instance);
// Add any client-specific initialization here
this.sessionId = null;
this.rpcUrl = `${this.url}/json`;
}
getClientType() {
return 'deluge';
}
async testConnection() {
try {
// Implement connection test logic
const response = await this.makeRequest('auth.check_session');
logToFile(`[Deluge:${this.name}] Connection test successful`);
return true;
} catch (error) {
logToFile(`[Deluge:${this.name}] Connection test failed: ${error.message}`);
return false;
}
}
async makeRequest(method, params = []) {
// Implement RPC call logic
const payload = {
method: method,
params: params,
id: Date.now()
};
// Add authentication if needed
if (this.sessionId) {
payload.params.unshift(this.sessionId);
}
// Make HTTP request to your client's API
// Handle authentication, errors, etc.
}
async getActiveDownloads() {
try {
// Fetch downloads from your client
const torrents = await this.makeRequest('core.get_torrents_status',
[{}, ['name', 'state', 'progress', 'total_size', 'download_payload_rate']]
);
// Normalize each download using the standard schema
return Object.entries(torrents).map(([id, torrent]) =>
this.normalizeDownload({ ...torrent, id })
);
} catch (error) {
logToFile(`[Deluge:${this.name}] Error fetching downloads: ${error.message}`);
return [];
}
}
async getClientStatus() {
try {
// Optional: Return client status information
const status = await this.makeRequest('core.get_session_status');
return status;
} catch (error) {
logToFile(`[Deluge:${this.name}] Error getting client status: ${error.message}`);
return null;
}
}
normalizeDownload(torrent) {
// Convert client-specific data to the normalized schema
return {
id: torrent.id,
title: torrent.name,
type: 'torrent',
client: 'deluge',
instanceId: this.id,
instanceName: this.name,
status: this.mapStatus(torrent.state),
progress: Math.round(torrent.progress * 100),
size: torrent.total_size,
downloaded: Math.round(torrent.total_size * torrent.progress),
speed: torrent.download_payload_rate,
eta: torrent.eta > 0 ? torrent.eta : null,
category: torrent.label || undefined,
tags: torrent.tracker ? [torrent.tracker] : [],
savePath: torrent.save_path,
addedOn: torrent.added_time ? new Date(torrent.added_time * 1000).toISOString() : undefined,
raw: torrent // Include original data for advanced use cases
};
}
mapStatus(state) {
// Map client-specific states to normalized statuses
const statusMap = {
'Downloading': 'Downloading',
'Seeding': 'Seeding',
'Paused': 'Paused',
'Checking': 'Checking',
'Error': 'Error',
'Queued': 'Queued'
};
return statusMap[state] || state;
}
}
module.exports = DelugeClient;
```
## Step 2: Add Configuration Support
Update `server/utils/config.js` to add support for your client's environment variables:
```javascript
function getDelugeInstances() {
return parseInstances(
process.env.DELUGE_INSTANCES,
process.env.DELUGE_URL,
null, // no apiKey for Deluge
process.env.DELUGE_USERNAME,
process.env.DELUGE_PASSWORD
);
}
// Add to module.exports
module.exports = {
// ... existing exports
getDelugeInstances,
// ... other exports
};
```
## Step 3: Register the Client
Update `server/utils/downloadClients.js` to include your client:
```javascript
const DelugeClient = require('../clients/DelugeClient');
// Add to clientClasses mapping
const clientClasses = {
sabnzbd: SABnzbdClient,
qbittorrent: QBittorrentClient,
transmission: TransmissionClient,
deluge: DelugeClient // Add your client here
};
// Update instance configuration
const instanceConfigs = [
...sabnzbdInstances.map(inst => ({ ...inst, type: 'sabnzbd' })),
...qbittorrentInstances.map(inst => ({ ...inst, type: 'qbittorrent' })),
...transmissionInstances.map(inst => ({ ...inst, type: 'transmission' })),
...delugeInstances.map(inst => ({ ...inst, type: 'deluge' })) // Add this line
];
```
## Step 4: Update Poller Integration
The poller automatically uses the registry, so no changes are needed there. However, if you want to maintain backward compatibility with existing cache keys, you may need to update the poller's transformation logic.
## Step 5: Add Tests
Create comprehensive tests for your client:
```javascript
// tests/unit/clients/DelugeClient.test.js
const DelugeClient = require('../../../server/clients/DelugeClient');
describe('DelugeClient', () => {
let client;
let mockConfig;
beforeEach(() => {
mockConfig = {
id: 'test-deluge',
name: 'Test Deluge',
url: 'http://localhost:8112',
username: 'admin',
password: 'deluge'
};
client = new DelugeClient(mockConfig);
});
describe('Constructor', () => {
it('should initialize with correct properties', () => {
expect(client.getClientType()).toBe('deluge');
expect(client.getInstanceId()).toBe('test-deluge');
expect(client.name).toBe('Test Deluge');
});
});
describe('Connection Test', () => {
it('should test connection successfully', async () => {
// Mock successful connection
client.makeRequest = jest.fn().mockResolvedValue({ result: true });
const result = await client.testConnection();
expect(result).toBe(true);
expect(client.makeRequest).toHaveBeenCalledWith('auth.check_session');
});
});
describe('Download Normalization', () => {
it('should normalize download data correctly', () => {
const torrent = {
id: 'abc123',
name: 'Test Torrent',
state: 'Downloading',
progress: 0.75,
total_size: 1000000000,
download_payload_rate: 1048576,
eta: 3600,
label: 'movies',
save_path: '/downloads/test'
};
const normalized = client.normalizeDownload(torrent);
expect(normalized).toEqual({
id: 'abc123',
title: 'Test Torrent',
type: 'torrent',
client: 'deluge',
instanceId: 'test-deluge',
instanceName: 'Test Deluge',
status: 'Downloading',
progress: 75,
size: 1000000000,
downloaded: 750000000,
speed: 1048576,
eta: 3600,
category: 'movies',
tags: [],
savePath: '/downloads/test',
raw: torrent
});
});
});
// Add more tests for error handling, edge cases, etc.
});
```
## Step 6: Configuration Examples
Add documentation for your client's configuration in `.env.sample`:
```bash
# Deluge Configuration
# Single instance (legacy format)
# DELUGE_URL=http://localhost:8112
# DELUGE_USERNAME=admin
# DELUGE_PASSWORD=deluge
# Multiple instances (JSON format)
DELUGE_INSTANCES='[
{
"name": "Main Deluge",
"url": "http://localhost:8112",
"username": "admin",
"password": "deluge"
},
{
"name": "Backup Deluge",
"url": "http://localhost:8113",
"username": "admin",
"password": "deluge"
}
]'
```
## Step 7: Update Documentation
Update relevant documentation files:
1. **ARCHITECTURE.md**: Add your client to the download clients section
2. **README.md**: Add configuration instructions for your client
3. **CHANGELOG.md**: Document the new client support
## Best Practices
### Error Handling
- Always wrap API calls in try-catch blocks
- Return empty arrays for download fetch failures
- Log errors with appropriate context
- Implement retry logic where appropriate
### Authentication
- Store credentials securely (don't log them)
- Handle session expiration gracefully
- Implement automatic re-authentication when possible
### Performance
- Use efficient API calls (batch requests when available)
- Implement caching for expensive operations
- Consider pagination for large download lists
- Use connection pooling for HTTP clients
### Normalization
- Always return the complete normalized schema
- Handle missing or null values gracefully
- Preserve original data in the `raw` field
- Map client-specific statuses to standard ones
### Testing
- Test both success and failure scenarios
- Mock external API calls
- Test normalization edge cases
- Include integration tests
## Example: Complete Implementation
For a complete example, refer to the existing client implementations:
- **SABnzbdClient.js**: Simple REST API client
- **QBittorrentClient.js**: Complex client with sync API and fallback
- **TransmissionClient.js**: JSON-RPC client with session management
- **RTorrentClient.js**: XML-RPC client with HTTP Basic Auth
### rTorrent Specific Notes
rTorrent uses XML-RPC over HTTP with the following specifics:
- **Endpoint**: `${url}/RPC2` (most common)
- **Authentication**: HTTP Basic Auth (handled by reverse proxy or web server)
- **Primary Method**: `d.multicall2` for efficient bulk torrent data retrieval
- **Library**: Uses the `xmlrpc` package (v1.3.2)
- **Status Mapping**: Combines `d.state`, `d.is_active`, and `d.is_hash_checking` to determine status
- **ETA Calculation**: Computed from download speed and remaining bytes when actively downloading
## Troubleshooting
### Common Issues
1. **Authentication failures**: Check credentials and URL format
2. **API changes**: Ensure your client matches the API version
3. **Network issues**: Implement proper timeout and retry logic
4. **Data normalization**: Verify all required fields are populated
### Debugging
- Enable debug logging in your client
- Check the server logs for error messages
- Use the test connection endpoint to verify configuration
- Test API calls manually before implementing
## Contributing
When contributing a new client:
1. Follow the existing code style and patterns
2. Include comprehensive tests
3. Update all relevant documentation
4. Test with multiple instances if supported
5. Consider edge cases and error scenarios
## Support
If you need help implementing a new client:
1. Review existing client implementations
2. Check the architecture documentation
3. Look at the test examples
4. Ask questions in the project discussions
---
*This guide covers the basics of adding a new download client. For more advanced scenarios, refer to the source code and existing implementations.*
+1296 -2514
View File
File diff suppressed because it is too large Load Diff
+9 -22
View File
@@ -1,37 +1,24 @@
{
"name": "sofarr",
"version": "1.5.3",
"version": "0.1.2",
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
"main": "server/index.js",
"scripts": {
"dev": "nodemon server/index.js",
"start": "node server/index.js",
"install:all": "npm install",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui",
"audit": "npm audit --audit-level=high",
"audit:fix": "npm audit fix",
"audit:critical": "npm audit --audit-level=critical"
"install:all": "npm install"
},
"dependencies": {
"axios": "^1.6.0",
"cookie-parser": "^1.4.6",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-rate-limit": "^7.0.0",
"helmet": "^7.0.0",
"jsdom": "^29.1.1",
"xmlrpc": "^1.3.2"
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"axios": "^1.6.0",
"node-cron": "^3.0.3",
"cookie-parser": "^1.4.6"
},
"devDependencies": {
"@vitest/coverage-v8": "^4.1.6",
"concurrently": "^7.6.0",
"nock": "^14.0.15",
"nodemon": "^3.1.14",
"supertest": "^7.2.2",
"vitest": "^4.1.6"
"nodemon": "^2.0.22",
"concurrently": "^7.6.0"
},
"keywords": [
"sabnzbd",
-5
View File
@@ -1,5 +0,0 @@
Contact: mailto:gordon@i3omb.com
Expires: 2026-12-31T23:59:00.000Z
Preferred-Languages: en
Canonical: https://git.i3omb.com/Gandalf/sofarr
Policy: https://git.i3omb.com/Gandalf/sofarr/src/branch/main/SECURITY.md
+68 -1046
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 543 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

+17 -121
View File
@@ -4,24 +4,14 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>sofarr - Your Downloads Dashboard</title>
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
<link rel="apple-touch-icon" sizes="192x192" href="favicon-192.png">
<meta name="theme-color" content="#1a1a2e">
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- Splash Screen -->
<div id="splash-screen" class="splash-screen">
<img src="images/sofarr-flashscreen.png" alt="sofarr" class="splash-logo">
</div>
<div class="app">
<!-- Login Form -->
<div id="login-container" class="login-container" style="display: none;">
<div class="login-box">
<img src="images/sofarr-flashscreen.png" alt="sofarr" class="login-logo">
<p class="login-subtitle">Login with your Emby credentials</p>
<h2>Login to Emby</h2>
<form id="login-form">
<div class="form-group">
<label for="username">Username:</label>
@@ -31,12 +21,6 @@
<label for="password">Password:</label>
<input type="password" id="password" name="password" required>
</div>
<div class="form-group form-group--checkbox">
<label class="checkbox-label">
<input type="checkbox" id="remember-me" name="rememberMe">
<span>Keep me logged in</span>
</label>
</div>
<button type="submit" class="login-btn">Login</button>
</form>
<div id="login-error" class="error-message" style="display: none;"></div>
@@ -46,19 +30,27 @@
<!-- Dashboard -->
<div id="dashboard-container" class="dashboard-container" style="display: none;">
<header class="app-header">
<h1><a href="#" class="title-link" id="title-home-link"><img src="favicon-192.png" alt="" class="title-logo">sofarr</a></h1>
<h1>sofarr</h1>
<div class="header-controls">
<div class="theme-switcher">
<button class="theme-btn active" data-theme="light">Light</button>
<button class="theme-btn" data-theme="dark">Dark</button>
<button class="theme-btn" data-theme="mono">Mono</button>
</div>
<div class="refresh-control">
<label for="refresh-rate">Refresh:</label>
<select id="refresh-rate">
<option value="1000">1s</option>
<option value="5000" selected>5s</option>
<option value="10000">10s</option>
<option value="0">Off</option>
</select>
</div>
<div id="admin-controls" class="admin-controls" style="display: none;">
<label class="toggle-label">
<input type="checkbox" id="show-all-toggle">
<span>Show all users</span>
</label>
<button id="status-btn" class="status-btn">Status</button>
</div>
<div class="user-info">
<span class="user-label">Current User:</span>
@@ -68,117 +60,21 @@
</div>
</header>
<div id="status-panel" class="status-panel" style="display: none;">
<!-- Status content gets rendered here -->
<div id="status-content"><p class="status-loading">Loading status...</p></div>
</div>
<!-- Webhooks Configuration Panel (sibling to status-panel) -->
<div class="webhooks-section" id="webhooks-section" style="display: none;">
<div class="webhooks-header" id="webhooks-header">
<h2>⚡ Webhooks Configuration</h2>
<span class="webhooks-toggle" id="webhooks-toggle"></span>
</div>
<div class="webhooks-content" id="webhooks-content" style="display: none;">
<div id="webhook-loading" class="webhook-loading" style="display: none;">Loading webhook status...</div>
<!-- Sonarr Webhook -->
<div class="webhook-instance">
<h3>Sonarr</h3>
<div class="webhook-status">
<span class="status-indicator" id="sonarr-status">○ Disabled</span>
<button id="enable-sonarr-webhook" class="enable-webhook-btn" style="display: none;">Enable Sofarr Webhooks</button>
<button id="test-sonarr-webhook" class="test-webhook-btn" style="display: none;">Test</button>
</div>
<div class="webhook-triggers" id="sonarr-triggers" style="display: none;">
<div class="trigger-item"><span class="trigger-label">On Grab</span><span class="trigger-value" id="sonarr-onGrab"></span></div>
<div class="trigger-item"><span class="trigger-label">On Download</span><span class="trigger-value" id="sonarr-onDownload"></span></div>
<div class="trigger-item"><span class="trigger-label">On Import</span><span class="trigger-value" id="sonarr-onImport"></span></div>
<div class="trigger-item"><span class="trigger-label">On Upgrade</span><span class="trigger-value" id="sonarr-onUpgrade"></span></div>
</div>
<div class="webhook-stats" id="sonarr-stats" style="display: none;">
<div class="webhook-stats-title">Statistics</div>
<div class="webhook-stats-grid">
<div class="webhook-stat"><span class="webhook-stat-label">Events Received</span><span class="webhook-stat-value" id="sonarr-events">0</span></div>
<div class="webhook-stat"><span class="webhook-stat-label">Polls Skipped</span><span class="webhook-stat-value" id="sonarr-polls">0</span></div>
<div class="webhook-stat"><span class="webhook-stat-label">Last Event</span><span class="webhook-stat-value" id="sonarr-last">Never</span></div>
</div>
</div>
</div>
<!-- Radarr Webhook -->
<div class="webhook-instance">
<h3>Radarr</h3>
<div class="webhook-status">
<span class="status-indicator" id="radarr-status">○ Disabled</span>
<button id="enable-radarr-webhook" class="enable-webhook-btn" style="display: none;">Enable Sofarr Webhooks</button>
<button id="test-radarr-webhook" class="test-webhook-btn" style="display: none;">Test</button>
</div>
<div class="webhook-triggers" id="radarr-triggers" style="display: none;">
<div class="trigger-item"><span class="trigger-label">On Grab</span><span class="trigger-value" id="radarr-onGrab"></span></div>
<div class="trigger-item"><span class="trigger-label">On Download</span><span class="trigger-value" id="radarr-onDownload"></span></div>
<div class="trigger-item"><span class="trigger-label">On Import</span><span class="trigger-value" id="radarr-onImport"></span></div>
<div class="trigger-item"><span class="trigger-label">On Upgrade</span><span class="trigger-value" id="radarr-onUpgrade"></span></div>
</div>
<div class="webhook-stats" id="radarr-stats" style="display: none;">
<div class="webhook-stats-title">Statistics</div>
<div class="webhook-stats-grid">
<div class="webhook-stat"><span class="webhook-stat-label">Events Received</span><span class="webhook-stat-value" id="radarr-events">0</span></div>
<div class="webhook-stat"><span class="webhook-stat-label">Polls Skipped</span><span class="webhook-stat-value" id="radarr-polls">0</span></div>
<div class="webhook-stat"><span class="webhook-stat-label">Last Event</span><span class="webhook-stat-value" id="radarr-last">Never</span></div>
</div>
</div>
</div>
</div>
</div>
<div id="error-message" class="error-message" style="display: none;"></div>
<div id="loading" class="loading" style="display: none;">Loading downloads...</div>
<div class="main-tabs">
<div class="tab-bar">
<button class="tab-btn active" data-tab="downloads">Active Downloads</button>
<button class="tab-btn" data-tab="history">Recently Completed</button>
</div>
<div class="tab-panel" id="tab-downloads">
<div class="downloads-container">
<div id="no-downloads" class="no-downloads" style="display: none;">
<p>No downloads found for your user.</p>
<p>Make sure your shows and movies are tagged with your username in Sonarr/Radarr.</p>
</div>
<div id="downloads-list" class="downloads-list"></div>
</div>
</div>
<div class="tab-panel" id="tab-history" style="display: none;">
<div class="history-container" id="history-container">
<div class="history-header">
<div class="history-controls">
<label class="history-days-label" for="history-days">Last</label>
<input type="number" id="history-days" class="history-days-input" value="7" min="1" max="90">
<span class="history-days-label">days</span>
<button id="history-refresh-btn" class="history-refresh-btn" title="Refresh history">&#8635;</button>
<label class="history-toggle-label" id="ignore-available-label" data-tooltip="Hide failed downloads where the item is already available on disk (i.e. a failed upgrade attempt)">
<input type="checkbox" id="ignore-available-toggle">
<span>Hide upgrade failures</span>
</label>
</div>
</div>
<div id="history-loading" class="history-loading" style="display: none;">Loading history...</div>
<div id="history-error" class="history-error" style="display: none;"></div>
<div id="no-history" class="no-history" style="display: none;">
<p>No completed downloads found in this period.</p>
</div>
<div id="history-list" class="history-list"></div>
</div>
<div class="downloads-container">
<h2>Your Downloads</h2>
<div id="no-downloads" class="no-downloads" style="display: none;">
<p>No downloads found for your user.</p>
<p>Make sure your shows and movies are tagged with your username in Sonarr/Radarr.</p>
</div>
<div id="downloads-list" class="downloads-list"></div>
</div>
<footer class="app-footer">
<p>Ensure your media is tagged with your username in Sonarr/Radarr to match downloads to users.</p>
<a href="https://git.i3omb.com/Gandalf/sofarr" target="_blank" rel="noopener noreferrer" class="app-version" id="app-version"></a>
</footer>
</div>
</div>
+46 -1154
View File
File diff suppressed because it is too large Load Diff
-119
View File
@@ -1,119 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Express application factory — imported by both server/index.js (production)
* and the test suite. Keeping app creation separate from app.listen() means
* tests can import a fresh instance without starting a real server or
* triggering the side-effects in index.js (log files, process.exit, poller).
*/
const express = require('express');
const cookieParser = require('cookie-parser');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const crypto = require('crypto');
const sabnzbdRoutes = require('./routes/sabnzbd');
const sonarrRoutes = require('./routes/sonarr');
const radarrRoutes = require('./routes/radarr');
const embyRoutes = require('./routes/emby');
const dashboardRoutes = require('./routes/dashboard');
const historyRoutes = require('./routes/history');
const authRoutes = require('./routes/auth');
const webhookRoutes = require('./routes/webhook');
const verifyCsrf = require('./middleware/verifyCsrf');
function createApp({ skipRateLimits = false } = {}) {
const app = express();
if (process.env.TRUST_PROXY) {
const trustValue = /^\d+$/.test(process.env.TRUST_PROXY)
? parseInt(process.env.TRUST_PROXY, 10)
: process.env.TRUST_PROXY;
app.set('trust proxy', trustValue);
}
// Per-request CSP nonce
app.use((req, res, next) => {
res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
next();
});
app.use((req, res, next) => {
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
styleSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
styleSrcAttr: ["'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'blob:'],
fontSrc: ["'self'", 'data:'],
connectSrc: ["'self'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"],
formAction: ["'self'"],
upgradeInsecureRequests: process.env.TRUST_PROXY ? [] : null
}
},
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
crossOriginEmbedderPolicy: false
})(req, res, next);
});
app.use((req, res, next) => {
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=()');
next();
});
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: skipRateLimits ? Number.MAX_SAFE_INTEGER : 300,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, please try again later' }
});
app.use(cookieParser(process.env.COOKIE_SECRET || undefined));
app.use(express.json({ limit: '64kb' }));
// Health / readiness (no auth, no rate-limit)
app.get('/health', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime() });
});
app.get('/ready', (req, res) => {
const ready = !!(process.env.EMBY_URL);
if (ready) {
res.json({ status: 'ready' });
} else {
res.status(503).json({ status: 'not ready', reason: 'EMBY_URL not configured' });
}
});
// API routes
app.use('/api', apiLimiter);
app.use('/api/auth', authRoutes);
app.use('/api/webhook', webhookRoutes);
// CSRF protection for all state-changing API requests below
app.use('/api', verifyCsrf);
app.use('/api/sabnzbd', sabnzbdRoutes);
app.use('/api/sonarr', sonarrRoutes);
app.use('/api/radarr', radarrRoutes);
app.use('/api/emby', embyRoutes);
app.use('/api/dashboard', dashboardRoutes);
app.use('/api/history', historyRoutes);
// Global error handler
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
console.error('[Server] Unhandled error:', err.message);
res.status(500).json({ error: 'Internal server error' });
});
return app;
}
module.exports = { createApp };
-78
View File
@@ -1,78 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Abstract base class for all *arr data retrievers.
* Defines the common interface that all retrievers must implement.
* This pluggable layer enables future retrieval strategies (e.g., webhook listeners)
* to push normalized data directly into the existing cache and SSE system
* without touching the poller logic.
*/
class ArrRetriever {
/**
* @param {Object} instanceConfig - Configuration for this retriever instance
* @param {string} instanceConfig.id - Unique identifier for this instance
* @param {string} instanceConfig.name - Display name for this instance
* @param {string} instanceConfig.url - Base URL for the *arr API
* @param {string} instanceConfig.apiKey - API key for authentication
*/
constructor(instanceConfig) {
if (this.constructor === ArrRetriever) {
throw new Error('ArrRetriever is an abstract class and cannot be instantiated directly');
}
this.id = instanceConfig.id;
this.name = instanceConfig.name;
this.url = instanceConfig.url;
this.apiKey = instanceConfig.apiKey;
}
/**
* Get the retriever type identifier (e.g., 'sonarr', 'radarr')
* @returns {string} The retriever type
*/
getRetrieverType() {
throw new Error('getRetrieverType() must be implemented by subclass');
}
/**
* Get the unique instance ID
* @returns {string} The instance ID
*/
getInstanceId() {
return this.id;
}
/**
* Get tags from this *arr instance
* @returns {Promise<Array>} Array of tag objects
*/
async getTags() {
throw new Error('getTags() must be implemented by subclass');
}
/**
* Get queue from this *arr instance
* @returns {Promise<Object>} Queue object with records array
*/
async getQueue() {
throw new Error('getQueue() must be implemented by subclass');
}
/**
* Get history from this *arr instance
* @param {Object} options - Optional parameters for history fetch
* @param {number} [options.pageSize] - Number of records to fetch
* @param {string} [options.sortKey] - Field to sort by
* @param {string} [options.sortDir] - Sort direction ('ascending' or 'descending')
* @param {boolean} [options.includeSeries] - Include series data (Sonarr)
* @param {boolean} [options.includeEpisode] - Include episode data (Sonarr)
* @param {boolean} [options.includeMovie] - Include movie data (Radarr)
* @param {string} [options.startDate] - ISO date string for filtering
* @returns {Promise<Object>} History object with records array
*/
async getHistory(options = {}) {
throw new Error('getHistory() must be implemented by subclass');
}
}
module.exports = ArrRetriever;
-103
View File
@@ -1,103 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Abstract base class for all download clients.
* Defines the common interface that all download clients must implement.
*/
class DownloadClient {
/**
* @param {Object} instanceConfig - Configuration for this client instance
* @param {string} instanceConfig.id - Unique identifier for this instance
* @param {string} instanceConfig.name - Display name for this instance
* @param {string} instanceConfig.url - Base URL for the client API
* @param {string} [instanceConfig.apiKey] - API key for authentication (if applicable)
* @param {string} [instanceConfig.username] - Username for authentication (if applicable)
* @param {string} [instanceConfig.password] - Password for authentication (if applicable)
*/
constructor(instanceConfig) {
if (this.constructor === DownloadClient) {
throw new Error('DownloadClient is an abstract class and cannot be instantiated directly');
}
this.id = instanceConfig.id;
this.name = instanceConfig.name;
this.url = instanceConfig.url;
this.apiKey = instanceConfig.apiKey;
this.username = instanceConfig.username;
this.password = instanceConfig.password;
}
/**
* Get the client type identifier (e.g., 'qbittorrent', 'sabnzbd', 'transmission')
* @returns {string} The client type
*/
getClientType() {
throw new Error('getClientType() must be implemented by subclass');
}
/**
* Get the unique instance ID
* @returns {string} The instance ID
*/
getInstanceId() {
return this.id;
}
/**
* Test connection to the download client
* @returns {Promise<boolean>} True if connection is successful
*/
async testConnection() {
throw new Error('testConnection() must be implemented by subclass');
}
/**
* Get active downloads from this client
* @returns {Promise<Array<NormalizedDownload>>} Array of normalized download objects
*/
async getActiveDownloads() {
throw new Error('getActiveDownloads() must be implemented by subclass');
}
/**
* Optional: Get client status information
* @returns {Promise<Object|null>} Client status object or null if not supported
*/
async getClientStatus() {
return null; // Default implementation - optional method
}
/**
* Normalize a download object to the standard schema
* @param {Object} download - Raw download object from client
* @returns {NormalizedDownload} Normalized download object
*/
normalizeDownload(download) {
throw new Error('normalizeDownload() must be implemented by subclass');
}
}
/**
* @typedef {Object} NormalizedDownload
* @property {string} id - Client-specific unique ID
* @property {string} title - Download title/name
* @property {'usenet'|'torrent'} type - Download type
* @property {string} client - Client identifier ('sabnzbd', 'qbittorrent', 'transmission', etc.)
* @property {string} instanceId - Instance identifier
* @property {string} instanceName - Instance display name
* @property {string} status - Normalized status (Downloading, Seeding, Paused, etc.)
* @property {number} progress - Progress percentage (0-100)
* @property {number} size - Total size in bytes
* @property {number} downloaded - Downloaded bytes
* @property {number} speed - Current speed in bytes/sec
* @property {number|null} eta - Estimated time remaining in seconds, null if unknown
* @property {string|undefined} category - Download category (optional)
* @property {string[]|undefined} tags - Download tags (optional)
* @property {string|undefined} savePath - Save path (optional)
* @property {string|undefined} addedOn - Added timestamp (optional)
* @property {number|undefined} arrQueueId - Sonarr/Radarr queue ID (optional)
* @property {'series'|'movie'|undefined} arrType - Sonarr/Radarr type (optional)
* @property {any|undefined} raw - Original client response (escape hatch)
*/
module.exports = DownloadClient;
-94
View File
@@ -1,94 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const axios = require('axios');
const ArrRetriever = require('./ArrRetriever');
const { logToFile } = require('../utils/logger');
/**
* Polling-based Radarr data retriever.
* Implements the ArrRetriever interface using direct HTTP polling.
*/
class PollingRadarrRetriever extends ArrRetriever {
constructor(instanceConfig) {
super(instanceConfig);
}
getRetrieverType() {
return 'radarr';
}
/**
* Get tags from Radarr instance
* @returns {Promise<Array>} Array of tag objects
*/
async getTags() {
try {
const response = await axios.get(`${this.url}/api/v3/tag`, {
headers: { 'X-Api-Key': this.apiKey }
});
return response.data;
} catch (error) {
logToFile(`[PollingRadarrRetriever] ${this.id} tags error: ${error.message}`);
return [];
}
}
/**
* Get queue from Radarr instance
* @returns {Promise<Object>} Queue object with records array
*/
async getQueue() {
try {
// Fetch with large page size to get all items (Radarr has pagination)
const response = await axios.get(`${this.url}/api/v3/queue`, {
headers: { 'X-Api-Key': this.apiKey },
params: { includeMovie: true, pageSize: 1000 }
});
return response.data;
} catch (error) {
logToFile(`[PollingRadarrRetriever] ${this.id} queue error: ${error.message}`);
return { records: [] };
}
}
/**
* Get history from Radarr instance
* @param {Object} options - Optional parameters for history fetch
* @param {number} [options.pageSize=10] - Number of records to fetch
* @param {string} [options.sortKey] - Field to sort by
* @param {string} [options.sortDir] - Sort direction ('ascending' or 'descending')
* @param {boolean} [options.includeMovie=true] - Include movie data
* @param {string} [options.startDate] - ISO date string for filtering
* @returns {Promise<Object>} History object with records array
*/
async getHistory(options = {}) {
const {
pageSize = 100,
sortKey,
sortDir,
includeMovie = true,
startDate
} = options;
try {
const params = {
pageSize,
includeMovie
};
if (sortKey) params.sortKey = sortKey;
if (sortDir) params.sortDir = sortDir;
if (startDate) params.startDate = startDate;
const response = await axios.get(`${this.url}/api/v3/history`, {
headers: { 'X-Api-Key': this.apiKey },
params
});
return response.data;
} catch (error) {
logToFile(`[PollingRadarrRetriever] ${this.id} history error: ${error.message}`);
return { records: [] };
}
}
}
module.exports = PollingRadarrRetriever;
-97
View File
@@ -1,97 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const axios = require('axios');
const ArrRetriever = require('./ArrRetriever');
const { logToFile } = require('../utils/logger');
/**
* Polling-based Sonarr data retriever.
* Implements the ArrRetriever interface using direct HTTP polling.
*/
class PollingSonarrRetriever extends ArrRetriever {
constructor(instanceConfig) {
super(instanceConfig);
}
getRetrieverType() {
return 'sonarr';
}
/**
* Get tags from Sonarr instance
* @returns {Promise<Array>} Array of tag objects
*/
async getTags() {
try {
const response = await axios.get(`${this.url}/api/v3/tag`, {
headers: { 'X-Api-Key': this.apiKey }
});
return response.data;
} catch (error) {
logToFile(`[PollingSonarrRetriever] ${this.id} tags error: ${error.message}`);
return [];
}
}
/**
* Get queue from Sonarr instance
* @returns {Promise<Object>} Queue object with records array
*/
async getQueue() {
try {
// Fetch with large page size to get all items (Sonarr has pagination)
const response = await axios.get(`${this.url}/api/v3/queue`, {
headers: { 'X-Api-Key': this.apiKey },
params: { includeSeries: true, includeEpisode: true, pageSize: 1000 }
});
return response.data;
} catch (error) {
logToFile(`[PollingSonarrRetriever] ${this.id} queue error: ${error.message}`);
return { records: [] };
}
}
/**
* Get history from Sonarr instance
* @param {Object} options - Optional parameters for history fetch
* @param {number} [options.pageSize=10] - Number of records to fetch
* @param {string} [options.sortKey] - Field to sort by
* @param {string} [options.sortDir] - Sort direction ('ascending' or 'descending')
* @param {boolean} [options.includeSeries=true] - Include series data
* @param {boolean} [options.includeEpisode=true] - Include episode data
* @param {string} [options.startDate] - ISO date string for filtering
* @returns {Promise<Object>} History object with records array
*/
async getHistory(options = {}) {
const {
pageSize = 100,
sortKey,
sortDir,
includeSeries = true,
includeEpisode = true,
startDate
} = options;
try {
const params = {
pageSize,
includeSeries,
includeEpisode
};
if (sortKey) params.sortKey = sortKey;
if (sortDir) params.sortDir = sortDir;
if (startDate) params.startDate = startDate;
const response = await axios.get(`${this.url}/api/v3/history`, {
headers: { 'X-Api-Key': this.apiKey },
params
});
return response.data;
} catch (error) {
logToFile(`[PollingSonarrRetriever] ${this.id} history error: ${error.message}`);
return { records: [] };
}
}
}
module.exports = PollingSonarrRetriever;
-256
View File
@@ -1,256 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const axios = require('axios');
const DownloadClient = require('./DownloadClient');
const { logToFile } = require('../utils/logger');
class QBittorrentClient extends DownloadClient {
constructor(instance) {
super(instance);
this.authCookie = null;
// Sync API incremental state
this.lastRid = 0;
this.torrentMap = new Map();
this.fallbackThisCycle = false;
}
getClientType() {
return 'qbittorrent';
}
async testConnection() {
try {
await this.login();
// Try a simple API call to verify connection
await this.makeRequest('/api/v2/app/version');
logToFile(`[qBittorrent:${this.name}] Connection test successful`);
return true;
} catch (error) {
logToFile(`[qBittorrent:${this.name}] Connection test failed: ${error.message}`);
return false;
}
}
async login() {
try {
logToFile(`[qBittorrent:${this.name}] Attempting login...`);
const response = await axios.post(`${this.url}/api/v2/auth/login`,
`username=${encodeURIComponent(this.username)}&password=${encodeURIComponent(this.password)}`,
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
maxRedirects: 0,
validateStatus: (status) => status >= 200 && status < 400
}
);
if (response.headers['set-cookie']) {
this.authCookie = response.headers['set-cookie'][0];
logToFile(`[qBittorrent:${this.name}] Login successful`);
return true;
}
logToFile(`[qBittorrent:${this.name}] Login failed - no cookie`);
return false;
} catch (error) {
logToFile(`[qBittorrent:${this.name}] Login error: ${error.message}`);
return false;
}
}
async makeRequest(endpoint, config = {}) {
const url = `${this.url}${endpoint}`;
if (!this.authCookie) {
const loggedIn = await this.login();
if (!loggedIn) {
throw new Error(`Failed to authenticate with ${this.name}`);
}
}
try {
const response = await axios.get(url, {
...config,
headers: {
...config.headers,
'Cookie': this.authCookie
}
});
return response;
} catch (error) {
// If unauthorized, try re-authenticating once
if (error.response && error.response.status === 403) {
logToFile(`[qBittorrent:${this.name}] Auth expired, re-authenticating...`);
this.authCookie = null;
const loggedIn = await this.login();
if (loggedIn) {
return axios.get(url, {
...config,
headers: {
...config.headers,
'Cookie': this.authCookie
}
});
}
}
throw error;
}
}
/**
* Fetches incremental torrent data using the qBittorrent Sync API.
*/
async getMainData() {
const response = await this.makeRequest(`/api/v2/sync/maindata?rid=${this.lastRid}`);
const data = response.data;
if (data.full_update) {
// Full refresh: rebuild the entire map
this.torrentMap.clear();
if (data.torrents) {
for (const [hash, props] of Object.entries(data.torrents)) {
this.torrentMap.set(hash, { ...props, hash });
}
}
} else {
// Delta update: merge changed fields into existing torrent objects
if (data.torrents) {
for (const [hash, delta] of Object.entries(data.torrents)) {
const existing = this.torrentMap.get(hash) || { hash };
this.torrentMap.set(hash, { ...existing, ...delta });
}
}
}
// Remove torrents that the server reports as deleted
if (data.torrents_removed) {
for (const hash of data.torrents_removed) {
this.torrentMap.delete(hash);
}
}
// Ensure every torrent has a computed 'completed' field for downstream consumers
for (const torrent of this.torrentMap.values()) {
if (torrent.completed === undefined && torrent.size !== undefined && torrent.progress !== undefined) {
torrent.completed = Math.round(torrent.size * torrent.progress);
}
}
this.lastRid = data.rid;
return Array.from(this.torrentMap.values());
}
/**
* Legacy full-list fetch. Used as a fallback when the Sync API fails.
*/
async getTorrentsLegacy() {
try {
const response = await this.makeRequest('/api/v2/torrents/info');
logToFile(`[qBittorrent:${this.name}] Retrieved ${response.data.length} torrents (legacy)`);
return response.data;
} catch (error) {
logToFile(`[qBittorrent:${this.name}] Error fetching torrents (legacy): ${error.message}`);
throw error;
}
}
async getActiveDownloads() {
try {
if (this.fallbackThisCycle) {
logToFile(`[qBittorrent:${this.name}] Already fell back this cycle, using legacy`);
const torrents = await this.getTorrentsLegacy();
return torrents.map(torrent => this.normalizeDownload(torrent));
}
const torrents = await this.getMainData();
logToFile(`[qBittorrent:${this.name}] Sync: ${torrents.length} torrents (rid=${this.lastRid})`);
return torrents.map(torrent => this.normalizeDownload(torrent));
} catch (error) {
logToFile(`[qBittorrent:${this.name}] Sync failed, falling back to legacy: ${error.message}`);
this.fallbackThisCycle = true;
try {
const torrents = await this.getTorrentsLegacy();
return torrents.map(torrent => this.normalizeDownload(torrent));
} catch (fallbackError) {
logToFile(`[qBittorrent:${this.name}] Fallback also failed: ${fallbackError.message}`);
return [];
}
}
}
async getClientStatus() {
try {
const response = await this.makeRequest('/api/v2/sync/maindata');
const data = response.data;
return {
serverState: data.server_state || {},
rid: data.rid,
fullUpdate: data.full_update
};
} catch (error) {
logToFile(`[qBittorrent:${this.name}] Error getting client status: ${error.message}`);
return null;
}
}
normalizeDownload(torrent) {
const totalSize = torrent.size;
const downloadedSize = torrent.completed || Math.round(torrent.size * torrent.progress);
const progress = torrent.progress * 100;
// Map qBittorrent states to our normalized status
const stateMap = {
'downloading': 'Downloading',
'stalledDL': 'Downloading',
'metaDL': 'Downloading',
'forcedDL': 'Downloading',
'allocating': 'Downloading',
'uploading': 'Seeding',
'stalledUP': 'Seeding',
'forcedUP': 'Seeding',
'queuedUP': 'Queued',
'queuedDL': 'Queued',
'checkingUP': 'Checking',
'checkingDL': 'Checking',
'checkingResumeData': 'Checking',
'moving': 'Moving',
'pausedUP': 'Paused',
'pausedDL': 'Paused',
'stoppedUP': 'Stopped',
'stoppedDL': 'Stopped',
'error': 'Error',
'missingFiles': 'Error',
'unknown': 'Unknown'
};
const status = stateMap[torrent.state] || torrent.state;
return {
id: torrent.hash,
title: torrent.name,
type: 'torrent',
client: 'qbittorrent',
instanceId: this.id,
instanceName: this.name,
status: status,
progress: Math.round(progress),
size: totalSize,
downloaded: downloadedSize,
speed: torrent.dlspeed,
eta: torrent.eta < 0 || torrent.eta === 8640000 ? null : torrent.eta,
category: torrent.category || undefined,
tags: torrent.tags ? torrent.tags.split(',').filter(tag => tag.trim()) : [],
savePath: torrent.content_path || torrent.save_path || undefined,
addedOn: torrent.added_on ? new Date(torrent.added_on * 1000).toISOString() : undefined,
raw: torrent
};
}
// Reset fallback flag (called by registry at start of each poll cycle)
resetFallbackFlag() {
this.fallbackThisCycle = false;
}
}
module.exports = QBittorrentClient;
-185
View File
@@ -1,185 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const xmlrpc = require('xmlrpc');
const DownloadClient = require('./DownloadClient');
const { logToFile } = require('../utils/logger');
/**
* rTorrent download client implementation.
* Communicates via XML-RPC over HTTP.
* Supports HTTP Basic Auth when username/password are configured.
* The URL field must include the full XML-RPC endpoint path (e.g., http://rtorrent.local:8080/RPC2 or https://user.whatbox.ca/xmlrpc).
*/
class RTorrentClient extends DownloadClient {
constructor(instance) {
super(instance);
this._createClient();
}
_createClient() {
const clientOptions = { url: this.url };
if (this.username && this.password) {
clientOptions.headers = {
Authorization: `Basic ${Buffer.from(`${this.username}:${this.password}`).toString('base64')}`
};
}
this.client = xmlrpc.createClient(clientOptions);
}
getClientType() {
return 'rtorrent';
}
async testConnection() {
try {
await this._methodCall('system.client_version');
logToFile(`[rtorrent:${this.name}] Connection test successful`);
return true;
} catch (error) {
logToFile(`[rtorrent:${this.name}] Connection test failed: ${error.message}`);
return false;
}
}
/**
* Wrap xmlrpc methodCall in a Promise.
* @param {string} method - XML-RPC method name
* @param {Array} params - Method parameters
* @returns {Promise<any>}
*/
_methodCall(method, params = []) {
return new Promise((resolve, reject) => {
this.client.methodCall(method, params, (error, value) => {
if (error) {
reject(error);
} else {
resolve(value);
}
});
});
}
async getActiveDownloads() {
try {
const torrents = await this._methodCall('d.multicall2', [
'',
'd.hash=',
'd.name=',
'd.size_bytes=',
'd.completed_bytes=',
'd.down.rate=',
'd.up.rate=',
'd.state=',
'd.is_active=',
'd.is_hash_checking=',
'd.directory=',
'd.custom1='
]);
logToFile(`[rtorrent:${this.name}] Retrieved ${torrents.length} torrents`);
return torrents.map(torrent => this.normalizeDownload(torrent));
} catch (error) {
logToFile(`[rtorrent:${this.name}] Error fetching torrents: ${error.message}`);
return [];
}
}
async getClientStatus() {
try {
const [downRate, upRate] = await Promise.all([
this._methodCall('throttle.global_down.rate'),
this._methodCall('throttle.global_up.rate')
]);
return {
globalDownRate: downRate,
globalUpRate: upRate
};
} catch (error) {
logToFile(`[rtorrent:${this.name}] Error getting client status: ${error.message}`);
return null;
}
}
normalizeDownload(torrent) {
const [
hash,
name,
sizeBytes,
completedBytes,
downRate,
upRate,
state,
isActive,
isHashChecking,
directory,
custom1
] = torrent;
const status = this._mapStatus(state, isActive, isHashChecking, completedBytes, sizeBytes);
const progress = sizeBytes > 0 ? Math.round((completedBytes / sizeBytes) * 100) : 0;
// Calculate ETA when actively downloading
let eta = null;
if (status === 'Downloading' && downRate > 0 && completedBytes < sizeBytes) {
eta = Math.round((sizeBytes - completedBytes) / downRate);
}
const arrInfo = this._extractArrInfo(name);
return {
id: hash,
title: name,
type: 'torrent',
client: 'rtorrent',
instanceId: this.id,
instanceName: this.name,
status,
progress,
size: sizeBytes,
downloaded: completedBytes,
speed: status === 'Seeding' ? upRate : downRate,
eta,
category: custom1 || undefined,
tags: custom1 ? [custom1] : [],
savePath: directory || undefined,
addedOn: undefined, // rtorrent does not expose added time via multicall2
arrQueueId: arrInfo.queueId,
arrType: arrInfo.type,
raw: torrent
};
}
_mapStatus(state, isActive, isHashChecking, completedBytes, sizeBytes) {
if (isHashChecking === 1) {
return 'Checking';
}
if (state === 0) {
return 'Stopped';
}
if (isActive === 1) {
return completedBytes >= sizeBytes && sizeBytes > 0 ? 'Seeding' : 'Downloading';
}
return 'Paused';
}
_extractArrInfo(filename) {
const seriesMatch = filename.match(/[-\s]S(\d{2})E(\d{2})/i);
if (seriesMatch) {
return { type: 'series' };
}
const movieMatch = filename.match(/\((\d{4})\)/);
if (movieMatch) {
return { type: 'movie' };
}
return {};
}
}
module.exports = RTorrentClient;
-239
View File
@@ -1,239 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const axios = require('axios');
const DownloadClient = require('./DownloadClient');
const { logToFile } = require('../utils/logger');
class SABnzbdClient extends DownloadClient {
constructor(instance) {
super(instance);
}
getClientType() {
return 'sabnzbd';
}
async testConnection() {
try {
const response = await this.makeRequest('', { mode: 'version' });
logToFile(`[SABnzbd:${this.name}] Connection test successful, version: ${response.data.version}`);
return true;
} catch (error) {
logToFile(`[SABnzbd:${this.name}] Connection test failed: ${error.message}`);
return false;
}
}
async makeRequest(additionalParams = {}, config = {}) {
const params = {
output: 'json',
apikey: this.apiKey,
...additionalParams
};
try {
const response = await axios.get(`${this.url}/api`, {
params,
...config
});
return response;
} catch (error) {
logToFile(`[SABnzbd:${this.name}] API request failed: ${error.message}`);
throw error;
}
}
async getActiveDownloads() {
try {
// Get both queue and history to provide complete picture
const [queueResponse, historyResponse] = await Promise.all([
this.makeRequest({ mode: 'queue' }),
this.makeRequest({ mode: 'history', limit: 10 })
]);
const queueData = queueResponse.data;
const historyData = historyResponse.data;
const downloads = [];
// Process active queue items
if (queueData.queue && queueData.queue.slots) {
for (const slot of queueData.queue.slots) {
downloads.push(this.normalizeDownload(slot, 'queue'));
}
}
// Process recent history items (last 10)
if (historyData.history && historyData.history.slots) {
for (const slot of historyData.history.slots) {
downloads.push(this.normalizeDownload(slot, 'history'));
}
}
logToFile(`[SABnzbd:${this.name}] Retrieved ${downloads.length} downloads`);
return downloads;
} catch (error) {
logToFile(`[SABnzbd:${this.name}] Error fetching downloads: ${error.message}`);
return [];
}
}
async getClientStatus() {
try {
const response = await this.makeRequest({ mode: 'queue' });
const queueData = response.data.queue;
if (!queueData) return null;
return {
status: queueData.status,
speed: queueData.speed,
kbpersec: queueData.kbpersec,
sizeleft: queueData.sizeleft,
mbleft: queueData.mbleft,
mb: queueData.mb,
diskspace1: queueData.diskspace1,
diskspace2: queueData.diskspace2,
loadavg: queueData.loadavg,
pause_int: queueData.pause_int
};
} catch (error) {
logToFile(`[SABnzbd:${this.name}] Error getting client status: ${error.message}`);
return null;
}
}
normalizeDownload(slot, source) {
const isHistory = source === 'history';
// Map SABnzbd statuses to normalized status
const statusMap = {
'Downloading': 'Downloading',
'Paused': 'Paused',
'Waiting': 'Queued',
'Completed': 'Completed',
'Failed': 'Error',
'Verifying': 'Checking',
'Extracting': 'Extracting',
'Moving': 'Moving',
'QuickCheck': 'Checking',
'Repairing': 'Repairing'
};
const status = statusMap[slot.status] || slot.status;
// Calculate progress
let progress = 0;
let downloaded = 0;
let size = 0;
if (slot.mb && slot.mbleft !== undefined) {
size = slot.mb * 1024 * 1024; // Convert MB to bytes
downloaded = (slot.mb - slot.mbleft) * 1024 * 1024;
progress = slot.mb > 0 ? ((slot.mb - slot.mbleft) / slot.mb) * 100 : 0;
} else if (slot.size) {
// Try to parse size string (e.g., "1.5 GB")
const sizeMatch = slot.size.match(/^([\d.]+)\s*(\w+)$/i);
if (sizeMatch) {
const [, sizeValue, sizeUnit] = sizeMatch;
const multiplier = this.getUnitMultiplier(sizeUnit);
size = parseFloat(sizeValue) * multiplier;
if (slot.sizeleft) {
const leftMatch = slot.sizeleft.match(/^([\d.]+)\s*(\w+)$/i);
if (leftMatch) {
const [, leftValue, leftUnit] = leftMatch;
const leftMultiplier = this.getUnitMultiplier(leftUnit);
downloaded = size - (parseFloat(leftValue) * leftMultiplier);
progress = size > 0 ? (downloaded / size) * 100 : 0;
}
}
}
}
// Extract Sonarr/Radarr info from nzb_name if present
const arrInfo = this.extractArrInfo(slot.nzb_name || slot.filename || '');
return {
id: slot.nzo_id || slot.id,
title: slot.filename || slot.nzb_name || 'Unknown',
type: 'usenet',
client: 'sabnzbd',
instanceId: this.id,
instanceName: this.name,
status: status,
progress: Math.round(progress),
size: Math.round(size),
downloaded: Math.round(downloaded),
speed: slot.kbpersec ? slot.kbpersec * 1024 : 0, // Convert KB/s to bytes/s
eta: this.calculateEta(slot.timeleft || slot.eta),
category: slot.cat || undefined,
tags: slot.labels ? (Array.isArray(slot.labels) ? slot.labels : slot.labels.split(',')).filter(tag => tag && tag.trim()) : [],
savePath: slot.final_name || undefined,
addedOn: slot.added ? new Date(slot.added * 1000).toISOString() : undefined,
arrQueueId: arrInfo.queueId,
arrType: arrInfo.type,
raw: { ...slot, source }
};
}
getUnitMultiplier(unit) {
const unitMap = {
'b': 1,
'byte': 1,
'bytes': 1,
'kb': 1024,
'k': 1024,
'mb': 1024 * 1024,
'm': 1024 * 1024,
'gb': 1024 * 1024 * 1024,
'g': 1024 * 1024 * 1024,
'tb': 1024 * 1024 * 1024 * 1024,
't': 1024 * 1024 * 1024 * 1024
};
return unitMap[unit.toLowerCase()] || 1;
}
calculateEta(timeLeft) {
if (!timeLeft || timeLeft === '0:00' || timeLeft === 'unknown') {
return null;
}
// Parse time in various formats: "0:05:30", "15:30", "330"
const parts = timeLeft.split(':').reverse();
let totalSeconds = 0;
if (parts.length === 1) {
// Just seconds
totalSeconds = parseInt(parts[0], 10);
} else if (parts.length === 2) {
// MM:SS
totalSeconds = parseInt(parts[0], 10) + parseInt(parts[1], 10) * 60;
} else if (parts.length === 3) {
// HH:MM:SS
totalSeconds = parseInt(parts[0], 10) + parseInt(parts[1], 10) * 60 + parseInt(parts[2], 10) * 3600;
}
return isNaN(totalSeconds) ? null : totalSeconds;
}
extractArrInfo(filename) {
// Try to extract Sonarr/Radarr info from filename patterns
// This is a simple implementation - could be enhanced with regex patterns
// Look for patterns like "Series Name - S01E02 - Episode Title"
const seriesMatch = filename.match(/[-\s]S(\d{2})E(\d{2})/i);
if (seriesMatch) {
return { type: 'series' };
}
// Look for movie year patterns like "Movie Title (2023)"
const movieMatch = filename.match(/\((\d{4})\)/);
if (movieMatch && !seriesMatch) {
return { type: 'movie' };
}
return {};
}
}
module.exports = SABnzbdClient;
-181
View File
@@ -1,181 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const axios = require('axios');
const DownloadClient = require('./DownloadClient');
const { logToFile } = require('../utils/logger');
class TransmissionClient extends DownloadClient {
constructor(instance) {
super(instance);
this.sessionId = null;
this.rpcUrl = `${this.url}/transmission/rpc`;
}
getClientType() {
return 'transmission';
}
async testConnection() {
try {
await this.makeRequest('session-get');
logToFile(`[Transmission:${this.name}] Connection test successful`);
return true;
} catch (error) {
logToFile(`[Transmission:${this.name}] Connection test failed: ${error.message}`);
return false;
}
}
async makeRequest(method, arguments_ = {}, config = {}) {
const payload = {
method,
arguments: arguments_
};
const headers = {
'Content-Type': 'application/json'
};
if (this.sessionId) {
headers['X-Transmission-Session-Id'] = this.sessionId;
}
try {
const response = await axios.post(this.rpcUrl, payload, {
headers,
...config
});
if (response.data.result !== 'success') {
throw new Error(`Transmission RPC error: ${response.data.result}`);
}
return response;
} catch (error) {
// Handle session ID conflict (409 Conflict)
if (error.response && error.response.status === 409) {
const sessionId = error.response.headers['x-transmission-session-id'];
if (sessionId) {
this.sessionId = sessionId;
logToFile(`[Transmission:${this.name}] Updated session ID`);
return this.makeRequest(method, arguments_, config);
}
}
logToFile(`[Transmission:${this.name}] RPC request failed: ${error.message}`);
throw error;
}
}
async getActiveDownloads() {
try {
// Get all torrents with detailed fields
const response = await this.makeRequest('torrent-get', {
fields: [
'id', 'name', 'hashString', 'status', 'totalSize', 'sizeWhenDone',
'leftUntilDone', 'rateDownload', 'rateUpload', 'eta', 'downloadedEver',
'uploadedEver', 'percentDone', 'addedDate', 'doneDate', 'trackerStats',
'labels', 'downloadDir', 'error', 'errorString', 'peersConnected',
'peersGettingFromUs', 'peersSendingToUs', 'queuePosition'
]
});
const torrents = response.data.arguments.torrents || [];
logToFile(`[Transmission:${this.name}] Retrieved ${torrents.length} torrents`);
return torrents.map(torrent => this.normalizeDownload(torrent));
} catch (error) {
logToFile(`[Transmission:${this.name}] Error fetching torrents: ${error.message}`);
return [];
}
}
async getClientStatus() {
try {
const response = await this.makeRequest('session-get');
const sessionStats = await this.makeRequest('session-stats');
return {
session: response.data.arguments,
stats: sessionStats.data.arguments
};
} catch (error) {
logToFile(`[Transmission:${this.name}] Error getting client status: ${error.message}`);
return null;
}
}
normalizeDownload(torrent) {
// Map Transmission status codes to normalized status
const statusMap = {
0: 'Stopped', // TORRENT_STOPPED
1: 'Queued', // TORRENT_CHECK_WAIT
2: 'Checking', // TORRENT_CHECK
3: 'Queued', // TORRENT_DOWNLOAD_WAIT
4: 'Downloading', // TORRENT_DOWNLOAD
5: 'Queued', // TORRENT_SEED_WAIT
6: 'Seeding', // TORRENT_SEED
7: 'Unknown' // TORRENT_IS_CHECKING (alias for 2)
};
const status = statusMap[torrent.status] || 'Unknown';
// Calculate progress and sizes
const progress = torrent.percentDone * 100;
const size = torrent.totalSize;
const downloaded = torrent.sizeWhenDone - torrent.leftUntilDone;
// Handle ETA - Transmission uses -1 for unknown, -2 for infinite
let eta = null;
if (torrent.eta >= 0) {
eta = torrent.eta;
}
// Extract category/labels
const labels = torrent.labels || [];
const category = labels.length > 0 ? labels[0] : undefined;
// Try to extract Sonarr/Radarr info from name
const arrInfo = this.extractArrInfo(torrent.name);
return {
id: torrent.hashString,
title: torrent.name,
type: 'torrent',
client: 'transmission',
instanceId: this.id,
instanceName: this.name,
status: status,
progress: Math.round(progress),
size: size,
downloaded: downloaded,
speed: torrent.rateDownload,
eta: eta,
category: category,
tags: labels,
savePath: torrent.downloadDir || undefined,
addedOn: torrent.addedDate ? new Date(torrent.addedDate * 1000).toISOString() : undefined,
arrQueueId: arrInfo.queueId,
arrType: arrInfo.type,
raw: torrent
};
}
extractArrInfo(filename) {
// Similar to SABnzbdClient, try to extract Sonarr/Radarr info
// Look for patterns like "Series Name - S01E02 - Episode Title"
const seriesMatch = filename.match(/[-\s]S(\d{2})E(\d{2})/i);
if (seriesMatch) {
return { type: 'series' };
}
// Look for movie year patterns like "Movie Title (2023)"
const movieMatch = filename.match(/\((\d{4})\)/);
if (movieMatch && !seriesMatch) {
return { type: 'movie' };
}
return {};
}
}
module.exports = TransmissionClient;
+12 -283
View File
@@ -1,45 +1,16 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express');
const cors = require('cors');
const path = require('path');
const cookieParser = require('cookie-parser');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const crypto = require('crypto');
const fs = require('fs');
const http = require('http');
const https = require('https');
require('dotenv').config();
require('./utils/loadSecrets')();
const { version } = require('../package.json');
// Setup logging with levels
// Levels: debug (0), info (1), warn (2), error (3), silent (4)
const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3, silent: 4 };
const currentLevel = LOG_LEVELS[(process.env.LOG_LEVEL || 'info').toLowerCase()] || 1;
// Log file lives in DATA_DIR so the non-root container user can write to it
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../data');
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
const LOG_PATH = path.join(DATA_DIR, 'server.log');
const LOG_MAX_BYTES = 10 * 1024 * 1024; // 10 MB per file
const LOG_KEEP = 3; // keep 3 rotated files
function rotateLogIfNeeded() {
try {
const stat = fs.statSync(LOG_PATH);
if (stat.size < LOG_MAX_BYTES) return;
for (let i = LOG_KEEP - 1; i >= 1; i--) {
const src = `${LOG_PATH}.${i}`;
const dst = `${LOG_PATH}.${i + 1}`;
if (fs.existsSync(src)) fs.renameSync(src, dst);
}
fs.renameSync(LOG_PATH, `${LOG_PATH}.1`);
} catch { /* ignore rotation errors — don't crash the server */ }
}
rotateLogIfNeeded();
const logFile = fs.createWriteStream(LOG_PATH, { flags: 'a' });
const logFile = fs.createWriteStream(path.join(__dirname, '../server.log'), { flags: 'a' });
const originalConsoleLog = console.log;
const originalConsoleError = console.error;
const originalConsoleWarn = console.warn;
@@ -82,273 +53,31 @@ const sonarrRoutes = require('./routes/sonarr');
const radarrRoutes = require('./routes/radarr');
const embyRoutes = require('./routes/emby');
const dashboardRoutes = require('./routes/dashboard');
const historyRoutes = require('./routes/history');
const authRoutes = require('./routes/auth');
const webhookRoutes = require('./routes/webhook');
const verifyCsrf = require('./middleware/verifyCsrf');
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
const { validateInstanceUrl } = require('./utils/config');
// ---------------------------------------------------------------------------
// Startup environment validation
// ---------------------------------------------------------------------------
const cookieSecret = process.env.COOKIE_SECRET;
if (!cookieSecret && process.env.NODE_ENV === 'production') {
console.error('[Security] COOKIE_SECRET is not set in production — aborting.');
process.exit(1);
} else if (!cookieSecret) {
console.warn('[Security] COOKIE_SECRET not set — unsigned cookies (dev only)');
} else if (cookieSecret.length < 32) {
console.warn('[Security] COOKIE_SECRET is shorter than 32 characters — use openssl rand -hex 32');
}
if (!process.env.EMBY_URL && process.env.NODE_ENV === 'production') {
console.error('[Config] EMBY_URL is required');
process.exit(1);
}
if (process.env.EMBY_URL) {
validateInstanceUrl(process.env.EMBY_URL, 'EMBY_URL');
}
const app = express();
const PORT = process.env.PORT || 3001;
// Resolve TLS_ENABLED early — used in Helmet CSP and server startup
const TLS_ENABLED = (process.env.TLS_ENABLED || 'true').toLowerCase() !== 'false';
app.use(cors());
app.use(cookieParser());
app.use(express.json());
app.use(express.static(path.join(__dirname, '../public')));
// ---------------------------------------------------------------------------
// Trust proxy — required when behind Nginx/Caddy/Traefik so that
// req.ip reflects the real client IP (not 127.0.0.1) and
// req.secure is true when the upstream TLS is terminated by the proxy.
// Set TRUST_PROXY=1 (or a specific IP/CIDR) via env.
// ---------------------------------------------------------------------------
if (process.env.TRUST_PROXY) {
const trustValue = /^\d+$/.test(process.env.TRUST_PROXY)
? parseInt(process.env.TRUST_PROXY, 10)
: process.env.TRUST_PROXY;
app.set('trust proxy', trustValue);
}
// ---------------------------------------------------------------------------
// Helmet v7 — security response headers
// CSP uses a per-request nonce injected into index.html so inline scripts
// and styles are allowed only with a valid nonce, not blanket unsafe-inline.
// ---------------------------------------------------------------------------
app.use((req, res, next) => {
// Generate a fresh nonce for every request
res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
next();
});
app.use((req, res, next) => {
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
styleSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
imgSrc: ["'self'", 'data:', 'blob:'],
fontSrc: ["'self'", 'data:'],
connectSrc: ["'self'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"],
formAction: ["'self'"],
upgradeInsecureRequests: (process.env.TRUST_PROXY || TLS_ENABLED) ? [] : null
}
},
hsts: {
maxAge: 31536000, // 1 year
includeSubDomains: true,
preload: true
},
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
crossOriginEmbedderPolicy: false // not needed for this SPA
})(req, res, next);
});
// Permissions-Policy — disable powerful browser features not needed by the app
app.use((req, res, next) => {
res.setHeader(
'Permissions-Policy',
'camera=(), microphone=(), geolocation=(), payment=(), usb=()'
);
next();
});
// ---------------------------------------------------------------------------
// General API rate limiter — applies to all /api/* routes
// More specific limiters (e.g. login) apply on top of this.
// ---------------------------------------------------------------------------
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 300, // 300 requests per IP per window (generous for polling)
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, please try again later' }
});
// ---------------------------------------------------------------------------
// Body parsing & cookies
// ---------------------------------------------------------------------------
app.use(cookieParser(cookieSecret || undefined));
app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads
// ---------------------------------------------------------------------------
// Health / readiness endpoints (no auth, no rate-limit)
// Used by Docker HEALTHCHECK and orchestrators.
// ---------------------------------------------------------------------------
app.get('/health', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime(), version });
});
app.get('/ready', (req, res) => {
// Confirm critical config is present
const ready = !!(process.env.EMBY_URL);
if (ready) {
res.json({ status: 'ready' });
} else {
res.status(503).json({ status: 'not ready', reason: 'EMBY_URL not configured' });
}
});
// ---------------------------------------------------------------------------
// Static files — served before API routes
// index.html is served manually so we can inject the CSP nonce
// ---------------------------------------------------------------------------
const PUBLIC_DIR = path.join(__dirname, '../public');
const INDEX_HTML = path.join(PUBLIC_DIR, 'index.html');
// Serve all static assets (js, css, images, icons) except index.html.
// JS and CSS get no-cache so browsers revalidate on every load (ETag still
// avoids re-downloading unchanged files; only a deploy changes the ETag).
app.use(express.static(PUBLIC_DIR, {
index: false,
setHeaders(res, filePath) {
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
res.setHeader('Cache-Control', 'no-cache');
}
}
}));
// Serve index.html with CSP nonce injected into <script> tags
function serveIndex(req, res) {
fs.readFile(INDEX_HTML, 'utf8', (err, html) => {
if (err) return res.status(500).send('Internal Server Error');
const nonce = res.locals.cspNonce;
// Only inject nonce into <script> tags — style-src 'self' already permits
// same-origin <link rel=stylesheet> without a nonce, and injecting a nonce
// onto <link> breaks mobile browsers / caching proxies (stale HTML carries
// the old nonce which no longer matches the per-request CSP header).
const patched = html
.replace(/<script([^>]*)>/gi, `<script nonce="${nonce}"$1>`);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(patched);
});
}
// ---------------------------------------------------------------------------
// API routes (rate-limited; auth routes exempt CSRF for login/csrf endpoints)
// CSRF protection applies to all state-changing /api/* requests except
// /api/auth/login (pre-auth) and /api/auth/csrf (issues the token).
// ---------------------------------------------------------------------------
app.use('/api', apiLimiter);
app.use('/api/auth', authRoutes);
app.use('/api/webhook', webhookRoutes);
// All routes below this point require CSRF validation on mutating methods
app.use('/api', verifyCsrf);
app.use('/api/sabnzbd', sabnzbdRoutes);
app.use('/api/sonarr', sonarrRoutes);
app.use('/api/radarr', radarrRoutes);
app.use('/api/emby', embyRoutes);
app.use('/api/dashboard', dashboardRoutes);
app.use('/api/history', historyRoutes);
app.use('/api/auth', authRoutes);
// SPA catch-all — serve index.html for any unmatched path
app.get('*', serveIndex);
// ---------------------------------------------------------------------------
// Global error handler — never leak stack traces to clients
// ---------------------------------------------------------------------------
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
console.error('[Server] Unhandled error:', err.message);
res.status(500).json({ error: 'Internal server error' });
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, '../public/index.html'));
});
// ---------------------------------------------------------------------------
// TLS / HTTPS support
// Set TLS_CERT and TLS_KEY to paths of your certificate and private key.
// If unset, defaults to the bundled snakeoil self-signed certificate
// (localhost/127.0.0.1 only — suitable for local testing).
// Set TLS_ENABLED=false to force plain HTTP even if cert files exist.
// ---------------------------------------------------------------------------
const CERTS_DIR = path.join(__dirname, '../certs');
const TLS_CERT_PATH = process.env.TLS_CERT || path.join(CERTS_DIR, 'snakeoil.crt');
const TLS_KEY_PATH = process.env.TLS_KEY || path.join(CERTS_DIR, 'snakeoil.key');
function loadTlsCredentials() {
if (!TLS_ENABLED) return null;
try {
return {
cert: fs.readFileSync(TLS_CERT_PATH),
key: fs.readFileSync(TLS_KEY_PATH)
};
} catch (err) {
console.warn(`[TLS] Could not load certificate files — falling back to HTTP. (${err.message})`);
return null;
}
}
const tlsCredentials = loadTlsCredentials();
const server = tlsCredentials
? https.createServer(tlsCredentials, app)
: http.createServer(app);
const protocol = tlsCredentials ? 'https' : 'http';
const isSnakeoil = TLS_ENABLED &&
(!process.env.TLS_CERT || process.env.TLS_CERT === TLS_CERT_PATH);
server.listen(PORT, () => {
app.listen(PORT, () => {
console.log(`=================================`);
console.log(` sofarr v${version} - Your Downloads Dashboard`);
console.log(` Server running on ${protocol}://localhost:${PORT}`);
if (tlsCredentials && isSnakeoil) {
console.warn(` [TLS] Using bundled snakeoil certificate (self-signed).`);
console.warn(` [TLS] Set TLS_CERT and TLS_KEY for a trusted certificate.`);
console.warn(` [TLS] Set TLS_ENABLED=false to disable TLS entirely.`);
} else if (tlsCredentials) {
console.log(` [TLS] Certificate: ${TLS_CERT_PATH}`);
} else {
console.warn(` [TLS] Running in plain HTTP mode — not suitable for production.`);
}
console.log(` sofarr - Your Downloads Dashboard`);
console.log(` Server running on port ${PORT}`);
console.log(` Log level: ${process.env.LOG_LEVEL || 'info'}`);
console.log(` Polling: ${POLLING_ENABLED ? POLL_INTERVAL + 'ms' : 'disabled (on-demand)'}`);
console.log(` Trust proxy: ${process.env.TRUST_PROXY || 'disabled'}`);
console.log(`=================================`);
startPoller();
});
// ---------------------------------------------------------------------------
// Graceful shutdown — handle SIGTERM (Docker stop) and SIGINT (Ctrl+C)
// Stop the poller, close the HTTP server (stops accepting new connections),
// then let Node drain existing keep-alive connections and exit cleanly.
// ---------------------------------------------------------------------------
const { stopPoller } = require('./utils/poller');
function shutdown(signal) {
console.log(`[Server] ${signal} received — shutting down gracefully`);
stopPoller();
server.close(() => {
console.log('[Server] HTTP server closed');
process.exit(0);
});
// Force exit after 10 s if connections don't drain
setTimeout(() => {
console.error('[Server] Forced exit after 10 s timeout');
process.exit(1);
}, 10000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
-22
View File
@@ -1,22 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
function requireAuth(req, res, next) {
const signed = !!process.env.COOKIE_SECRET;
const raw = signed ? req.signedCookies.emby_user : req.cookies.emby_user;
if (!raw || raw === false) {
return res.status(401).json({ error: 'Not authenticated' });
}
let u;
try {
u = JSON.parse(raw);
} catch {
return res.status(401).json({ error: 'Invalid session' });
}
// Schema validation
if (typeof u.id !== 'string' || !u.id) return res.status(401).json({ error: 'Invalid session' });
if (typeof u.name !== 'string' || !u.name) return res.status(401).json({ error: 'Invalid session' });
if (typeof u.isAdmin !== 'boolean') u.isAdmin = !!u.isAdmin;
req.user = u;
next();
}
module.exports = requireAuth;
-43
View File
@@ -1,43 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* CSRF protection using the double-submit cookie pattern.
*
* On login the server issues a random `csrf_token` cookie (httpOnly:false
* so JS can read it). The SPA must send the same value in the
* `X-CSRF-Token` request header for every state-changing request (POST,
* PUT, PATCH, DELETE).
*
* Because the `sameSite: strict` session cookie already provides strong
* protection in modern browsers, this acts as defence-in-depth for
* older browsers and any edge cases.
*
* Safe methods (GET, HEAD, OPTIONS) are exempted.
*/
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
function verifyCsrf(req, res, next) {
if (SAFE_METHODS.has(req.method)) return next();
const cookieToken = req.cookies.csrf_token;
const headerToken = req.headers['x-csrf-token'];
if (!cookieToken || !headerToken) {
return res.status(403).json({ error: 'CSRF token missing' });
}
// Constant-time comparison to prevent timing attacks
if (cookieToken.length !== headerToken.length) {
return res.status(403).json({ error: 'CSRF token invalid' });
}
const a = Buffer.from(cookieToken);
const b = Buffer.from(headerToken);
if (!require('crypto').timingSafeEqual(a, b)) {
return res.status(403).json({ error: 'CSRF token invalid' });
}
next();
}
module.exports = verifyCsrf;
+49 -139
View File
@@ -1,108 +1,60 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express');
const axios = require('axios');
const crypto = require('crypto');
const rateLimit = require('express-rate-limit');
const router = express.Router();
// Persistent JSON file-backed token store — survives restarts
const { storeToken, getToken, clearToken } = require('../utils/tokenStore');
// Read EMBY_URL at request time (not module load time) so the value
// can be overridden by environment variables set after the module loads.
const getEmbyUrl = () => process.env.EMBY_URL;
// Strict login limiter: 10 attempts per 15 min, then locked for the window.
// Set SKIP_RATE_LIMIT=1 in the test environment to prevent the limiter from
// interfering with integration tests (all requests come from 127.0.0.1).
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: process.env.SKIP_RATE_LIMIT ? Number.MAX_SAFE_INTEGER : 10,
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: true, // only count failures toward the limit
message: { success: false, error: 'Too many login attempts, please try again later' }
});
const EMBY_URL = process.env.EMBY_URL;
const EMBY_API_KEY = process.env.EMBY_API_KEY;
// Authenticate user with Emby
router.post('/login', loginLimiter, async (req, res) => {
router.post('/login', async (req, res) => {
try {
const { username, password, rememberMe } = req.body;
// Input validation — reject obviously invalid inputs before hitting Emby
if (typeof username !== 'string' || username.trim().length === 0 || username.length > 128) {
return res.status(400).json({ success: false, error: 'Invalid username' });
}
if (typeof password !== 'string' || password.length === 0 || password.length > 256) {
return res.status(400).json({ success: false, error: 'Invalid password' });
}
const { username, password } = req.body;
console.log(`[Auth] Attempting login for user: ${username.trim()}`);
console.log(`[Auth] Attempting login for user: ${username}`);
// Authenticate with Emby using a stable DeviceId derived from the username.
// Using a deterministic DeviceId causes Emby to reuse the existing session
// for this device rather than creating a new one on each login.
const stableDeviceId = 'sofarr-' + crypto.createHash('sha256').update(username.trim().toLowerCase()).digest('hex').slice(0, 16);
const authResponse = await axios.post(`${getEmbyUrl()}/Users/authenticatebyname`, {
Username: username.trim(),
// Authenticate with Emby
const authResponse = await axios.post(`${EMBY_URL}/Users/authenticatebyname`, {
Username: username,
Pw: password
}, {
headers: {
'X-Emby-Authorization': `MediaBrowser Client="sofarr", Device="sofarr", DeviceId="${stableDeviceId}", Version="1.0.0"`
'X-Emby-Authorization': `MediaBrowser Client="MediaDashboard", Device="Browser", DeviceId="dashboard-${Date.now()}", Version="1.0.0"`
}
});
const authData = authResponse.data;
console.log(`[Auth] Emby auth response:`, JSON.stringify(authData));
// Get user info using the access token
const userResponse = await axios.get(`${getEmbyUrl()}/Users/${authData.User.Id || authData.User.id}`, {
const userResponse = await axios.get(`${EMBY_URL}/Users/${authData.User.Id || authData.User.id}`, {
headers: {
'X-MediaBrowser-Token': authData.AccessToken
}
});
const user = userResponse.data;
const isAdmin = !!(user.Policy && user.Policy.IsAdministrator);
console.log(`[Auth] Login successful for user: ${user.Name}, isAdmin: ${isAdmin}`);
// Store token server-side; it is never sent to the client.
storeToken(user.Id, authData.AccessToken);
console.log(`[Auth] User info:`, JSON.stringify(user));
console.log(`[Auth] Login successful for user: ${user.Name}`);
// Set authentication cookie (signed when COOKIE_SECRET is set).
// rememberMe=true → persistent cookie, expires in 30 days
// rememberMe=false → session cookie, expires when browser closes
// secure:true only when TRUST_PROXY is set — i.e. a TLS-terminating reverse
// proxy is in front. Without it the app may be accessed over plain HTTP and
// secure cookies would never be sent back by the browser.
const cookiePayload = JSON.stringify({ id: user.Id, name: user.Name, isAdmin });
const signed = !!process.env.COOKIE_SECRET;
const secureCookie = !!process.env.TRUST_PROXY; // only send over HTTPS when behind a TLS proxy
const cookieOptions = {
// Set authentication cookie
const isAdmin = !!(user.Policy && user.Policy.IsAdministrator);
res.cookie('emby_user', JSON.stringify({
id: user.Id,
name: user.Name,
isAdmin: isAdmin,
token: authData.AccessToken
}), {
httpOnly: true,
secure: secureCookie,
sameSite: 'strict',
signed,
path: '/'
};
if (rememberMe) {
cookieOptions.maxAge = 30 * 24 * 60 * 60 * 1000; // 30 days
}
res.cookie('emby_user', cookiePayload, cookieOptions);
// Issue a CSRF token tied to this session so state-changing endpoints
// can validate the double-submit cookie pattern
const csrfToken = crypto.randomBytes(32).toString('hex');
res.cookie('csrf_token', csrfToken, {
httpOnly: false, // intentionally readable by JS for the double-submit pattern
secure: secureCookie,
sameSite: 'strict',
path: '/'
maxAge: 24 * 60 * 60 * 1000 // 24 hours
});
res.json({
success: true,
user: { id: user.Id, name: user.Name, isAdmin },
csrfToken
user: {
id: user.Id,
name: user.Name,
isAdmin: isAdmin
}
});
} catch (error) {
console.error(`[Auth] Login failed:`, error.message);
@@ -113,75 +65,33 @@ router.post('/login', loginLimiter, async (req, res) => {
}
});
function parseSessionCookie(req) {
const signed = !!process.env.COOKIE_SECRET;
const raw = signed ? req.signedCookies.emby_user : req.cookies.emby_user;
if (!raw || raw === false) return null; // false = tampered signed cookie
try {
const u = JSON.parse(raw);
// Schema validation: require id (string), name (string), isAdmin (boolean)
if (typeof u.id !== 'string' || !u.id) return null;
if (typeof u.name !== 'string' || !u.name) return null;
if (typeof u.isAdmin !== 'boolean') u.isAdmin = !!u.isAdmin;
return u;
} catch {
return null;
}
}
// Get current authenticated user
router.get('/me', (req, res) => {
const user = parseSessionCookie(req);
if (!user) return res.json({ authenticated: false });
res.json({
authenticated: true,
user: { id: user.id, name: user.name, isAdmin: user.isAdmin }
});
});
// CSRF token refresh — lets the SPA get a new token without re-logging-in
// (e.g. after a page reload where the JS variable was lost)
router.get('/csrf', (req, res) => {
const csrfToken = crypto.randomBytes(32).toString('hex');
res.cookie('csrf_token', csrfToken, {
httpOnly: false,
secure: !!process.env.TRUST_PROXY,
sameSite: 'strict',
path: '/'
});
res.json({ csrfToken });
try {
const userCookie = req.cookies.emby_user;
if (!userCookie) {
return res.json({ authenticated: false });
}
const user = JSON.parse(userCookie);
res.json({
authenticated: true,
user: {
id: user.id,
name: user.name,
isAdmin: !!user.isAdmin
}
});
} catch (error) {
console.error(`[Auth] Error getting current user:`, error.message);
res.json({ authenticated: false });
}
});
// Logout
router.post('/logout', async (req, res) => {
const user = parseSessionCookie(req);
if (user) {
const stored = getToken(user.id);
if (stored) {
try {
await axios.post(`${getEmbyUrl()}/Sessions/Logout`, {}, {
headers: { 'X-MediaBrowser-Token': stored.accessToken }
});
console.log(`[Auth] Revoked Emby token for user: ${user.name}`);
} catch (err) {
console.warn(`[Auth] Failed to revoke Emby token for ${user.name}:`, err.message);
}
clearToken(user.id);
}
}
res.clearCookie('emby_user', {
httpOnly: true,
secure: !!process.env.TRUST_PROXY,
sameSite: 'strict',
signed: !!process.env.COOKIE_SECRET,
path: '/'
});
res.clearCookie('csrf_token', {
httpOnly: false,
secure: !!process.env.TRUST_PROXY,
sameSite: 'strict',
path: '/'
});
router.post('/logout', (req, res) => {
res.clearCookie('emby_user');
res.json({ success: true });
});
+293 -987
View File
File diff suppressed because it is too large Load Diff
+16 -18
View File
@@ -1,53 +1,51 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express');
const axios = require('axios');
const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const sanitizeError = require('../utils/sanitizeError');
router.use(requireAuth);
const EMBY_URL = process.env.EMBY_URL;
const EMBY_API_KEY = process.env.EMBY_API_KEY;
// Get active sessions
router.get('/sessions', async (req, res) => {
try {
const response = await axios.get(`${process.env.EMBY_URL}/Sessions`, {
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
const response = await axios.get(`${EMBY_URL}/Sessions`, {
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch Emby sessions', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch Emby sessions', details: error.message });
}
});
// Get user by ID
router.get('/users/:id', async (req, res) => {
try {
const response = await axios.get(`${process.env.EMBY_URL}/Users/${req.params.id}`, {
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
const response = await axios.get(`${EMBY_URL}/Users/${req.params.id}`, {
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch user details', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch user details', details: error.message });
}
});
// Get all users
router.get('/users', async (req, res) => {
try {
const response = await axios.get(`${process.env.EMBY_URL}/Users`, {
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
const response = await axios.get(`${EMBY_URL}/Users`, {
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch users', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch users', details: error.message });
}
});
// Get current user by session ID
router.get('/session/:sessionId/user', async (req, res) => {
try {
const response = await axios.get(`${process.env.EMBY_URL}/Sessions`, {
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
const response = await axios.get(`${EMBY_URL}/Sessions`, {
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
});
const session = response.data.find(s => s.Id === req.params.sessionId);
@@ -55,13 +53,13 @@ router.get('/session/:sessionId/user', async (req, res) => {
return res.status(404).json({ error: 'Session not found' });
}
const userResponse = await axios.get(`${process.env.EMBY_URL}/Users/${session.UserId}`, {
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
const userResponse = await axios.get(`${EMBY_URL}/Users/${session.UserId}`, {
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
});
res.json(userResponse.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch user from session', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch user from session', details: error.message });
}
});
-386
View File
@@ -1,386 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express');
const router = express.Router();
const axios = require('axios');
const requireAuth = require('../middleware/requireAuth');
const cache = require('../utils/cache');
const { fetchSonarrHistory, fetchRadarrHistory, classifySonarrEvent, classifyRadarrEvent } = require('../utils/historyFetcher');
const { getSonarrInstances, getRadarrInstances } = require('../utils/config');
const sanitizeError = require('../utils/sanitizeError');
// Re-use the same tag/cover-art helpers as dashboard.js by importing them
// from a shared location. For now they are inlined here to keep dashboard.js
// untouched (zero-conflict v1 merges). If these diverge they can be extracted
// into server/utils/dashboardHelpers.js in a later refactor.
function getCoverArt(item) {
if (!item || !item.images) return null;
const poster = item.images.find(img => img.coverType === 'poster');
if (poster) return poster.remoteUrl || poster.url || null;
const fanart = item.images.find(img => img.coverType === 'fanart');
return fanart ? (fanart.remoteUrl || fanart.url || null) : null;
}
function sanitizeTagLabel(input) {
if (!input) return '';
return input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
}
function tagMatchesUser(tag, username) {
if (!tag || !username) return false;
const tagLower = tag.toLowerCase();
if (tagLower === username) return true;
if (tagLower === sanitizeTagLabel(username)) return true;
return false;
}
function extractAllTags(tags, tagMap) {
if (!tags || tags.length === 0) return [];
if (tagMap) return tags.map(id => tagMap.get(id)).filter(Boolean);
return tags.map(t => t && t.label).filter(Boolean);
}
function extractUserTag(tags, tagMap, username) {
const allLabels = extractAllTags(tags, tagMap);
if (!allLabels.length) return null;
if (username) {
const match = allLabels.find(label => tagMatchesUser(label, username));
if (match) return match;
}
return null;
}
async function getEmbyUsers() {
const cached = cache.get('emby:users');
if (cached) return cached;
try {
const embyUrl = process.env.EMBY_URL;
const embyKey = process.env.EMBY_API_KEY;
if (!embyUrl || !embyKey) return new Map();
const res = await axios.get(`${embyUrl}/Users`, { params: { api_key: embyKey } });
const users = res.data || [];
const map = new Map();
for (const u of users) {
if (!u.Name) continue;
const lower = u.Name.toLowerCase();
map.set(lower, u.Name);
map.set(sanitizeTagLabel(lower), u.Name);
}
cache.set('emby:users', map, 60000);
return map;
} catch (err) {
console.error('[History] Failed to fetch Emby users:', err.message);
return new Map();
}
}
function buildTagBadges(allTags, embyUserMap) {
return allTags.map(label => {
const lower = label.toLowerCase();
const sanitized = sanitizeTagLabel(label);
const matchedUser = embyUserMap.get(lower) || embyUserMap.get(sanitized) || null;
return { label, matchedUser };
});
}
// Extract episode info from a Sonarr history record.
function extractEpisode(record) {
const ep = record.episode || {};
const s = ep.seasonNumber != null ? ep.seasonNumber : record.seasonNumber;
const e = ep.episodeNumber != null ? ep.episodeNumber : record.episodeNumber;
if (s == null || e == null) return null;
const title = ep.title || null;
return { season: s, episode: e, title };
}
// Find all episodes associated with a download by matching all history records
// that share the same source title. Returns sorted, deduplicated array.
function gatherEpisodes(titleLower, records) {
const episodes = [];
const seen = new Set();
for (const r of records) {
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
if (rTitle && (rTitle.includes(titleLower) || titleLower.includes(rTitle))) {
const ep = extractEpisode(r);
if (ep) {
const key = `${ep.season}x${ep.episode}`;
if (!seen.has(key)) {
seen.add(key);
episodes.push(ep);
}
}
}
}
episodes.sort((a, b) => a.season - b.season || a.episode - b.episode);
return episodes;
}
/**
* Deduplicate history items so that for each unique content item (episode or
* movie) only the most-recent record is shown, with the following rules:
*
* - If the most recent event is 'imported' → show it; suppress older failures.
* - If the most recent event is 'failed' and the item currently has a file
* (hasFile = true) → show the failure but flag it as availableForUpgrade:true
* so the UI can indicate the item is available but an upgrade is in progress.
* - If the most recent event is 'failed' and hasFile is false → show normally.
*
* Items are keyed by: type + instanceName + contentId (episodeId or movieId).
* Records without a contentId fall through unchanged (no deduplication possible).
*
* @param {Array} items - Already-built history items (unsorted)
* @param {Array} sonarrRaw - Raw Sonarr records (for hasFile lookup)
* @param {Array} radarrRaw - Raw Radarr records (for hasFile lookup)
* @returns {Array}
*/
function deduplicateHistoryItems(items, sonarrRaw, radarrRaw) {
// Build hasFile lookup: contentId → boolean
const sonarrHasFile = new Map();
for (const r of sonarrRaw) {
const id = r.episodeId;
if (id != null) {
const hf = r.episode && r.episode.hasFile != null ? r.episode.hasFile : undefined;
if (hf !== undefined && !sonarrHasFile.has(id)) sonarrHasFile.set(id, hf);
}
}
const radarrHasFile = new Map();
for (const r of radarrRaw) {
const id = r.movieId;
if (id != null) {
const hf = r.movie && r.movie.hasFile != null ? r.movie.hasFile : undefined;
if (hf !== undefined && !radarrHasFile.has(id)) radarrHasFile.set(id, hf);
}
}
// Group items by dedup key; preserve insertion order (newest first from caller)
const groups = new Map();
const noKey = [];
for (const item of items) {
const cid = item._contentId;
if (cid == null) { noKey.push(item); continue; }
const key = `${item.type}|${item.instanceName}|${cid}`;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(item);
}
const result = [...noKey];
for (const [, group] of groups) {
// group[0] is the most recent (items are pushed in date-descending order)
const best = group[0];
if (best.outcome === 'imported') {
result.push(best);
continue;
}
if (best.outcome === 'failed') {
const hasFile = best.type === 'series'
? sonarrHasFile.get(best._contentId)
: radarrHasFile.get(best._contentId);
if (hasFile) best.availableForUpgrade = true;
result.push(best);
continue;
}
result.push(best);
}
return result;
}
function getSonarrLink(series) {
if (!series || !series._instanceUrl || !series.titleSlug) return null;
return `${series._instanceUrl}/series/${series.titleSlug}`;
}
function getRadarrLink(movie) {
if (!movie || !movie._instanceUrl || !movie.titleSlug) return null;
return `${movie._instanceUrl}/movie/${movie.titleSlug}`;
}
/**
* GET /api/history/recent
*
* Returns Sonarr/Radarr history records (imported + failed) for the
* authenticated user, filtered to the last RECENT_COMPLETED_DAYS days
* (default 7, overridable via env or ?days= query param).
*
* Response shape:
* {
* user: string,
* isAdmin: boolean,
* days: number,
* history: HistoryItem[]
* }
*
* HistoryItem shape:
* {
* type: 'series'|'movie',
* outcome: 'imported'|'failed',
* title: string, // sourceTitle from arr record
* seriesName?: string, // series.title (Sonarr)
* movieName?: string, // movie.title (Radarr)
* coverArt: string|null,
* completedAt: string, // ISO date string from arr record
* quality: string|null,
* instanceName: string, // arr instance name
* arrLink: string|null, // link to item in Sonarr/Radarr UI
* allTags: string[],
* matchedUserTag: string|null,
* // admin-only:
* arrRecordId?: number,
* failureMessage?: string,
* }
*/
router.get('/recent', requireAuth, async (req, res) => {
try {
const user = req.user;
const username = user.name.toLowerCase();
const isAdmin = !!user.isAdmin;
const showAll = isAdmin && req.query.showAll === 'true';
const defaultDays = parseInt(process.env.RECENT_COMPLETED_DAYS, 10) || 7;
const requestedDays = parseInt(req.query.days, 10);
const days = (requestedDays > 0 && requestedDays <= 90) ? requestedDays : defaultDays;
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
// Fetch tag maps and history in parallel
const sonarrInstances = getSonarrInstances();
const radarrInstances = getRadarrInstances();
const [sonarrHistory, radarrHistory, embyUserMap] = await Promise.all([
fetchSonarrHistory(since),
fetchRadarrHistory(since),
showAll ? getEmbyUsers() : Promise.resolve(new Map())
]);
// Build tag maps from the cached poll data where available,
// falling back to what's embedded in history records
const sonarrTagsData = cache.get('poll:sonarr-tags') || [];
const radarrTagsData = cache.get('poll:radarr-tags') || [];
const sonarrTagMap = new Map(sonarrTagsData.flatMap(t => t.data || []).map(t => [t.id, t.label]));
const radarrTagMap = new Map(radarrTagsData.map(t => [t.id, t.label]));
const historyItems = [];
// --- Sonarr history ---
for (const record of sonarrHistory) {
try {
const outcome = classifySonarrEvent(record.eventType);
if (outcome === 'other') continue;
const series = record.series;
if (!series) continue;
const allTags = extractAllTags(series.tags, sonarrTagMap);
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
const hasAnyTag = allTags.length > 0;
if (!(showAll ? hasAnyTag : !!matchedUserTag)) continue;
const quality = record.quality && record.quality.quality && record.quality.quality.name
? record.quality.quality.name
: null;
const sourceTitle = record.sourceTitle || record.title || series.title;
const item = {
type: 'series',
outcome,
title: sourceTitle,
seriesName: series.title,
episodes: gatherEpisodes(sourceTitle.toLowerCase(), sonarrHistory),
coverArt: getCoverArt(series),
completedAt: record.date,
quality,
instanceName: record._instanceName || null,
arrLink: getSonarrLink(series),
allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined,
_contentId: record.episodeId != null ? record.episodeId : null
};
if (isAdmin) {
item.arrRecordId = record.id;
if (outcome === 'failed' && record.data && record.data.message) {
item.failureMessage = record.data.message;
}
}
historyItems.push(item);
} catch (err) {
console.error('[History] Error processing Sonarr record:', err.message);
}
}
// --- Radarr history ---
for (const record of radarrHistory) {
try {
const outcome = classifyRadarrEvent(record.eventType);
if (outcome === 'other') continue;
const movie = record.movie;
if (!movie) continue;
const allTags = extractAllTags(movie.tags, radarrTagMap);
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
const hasAnyTag = allTags.length > 0;
if (!(showAll ? hasAnyTag : !!matchedUserTag)) continue;
const quality = record.quality && record.quality.quality && record.quality.quality.name
? record.quality.quality.name
: null;
const item = {
type: 'movie',
outcome,
title: record.sourceTitle || record.title || movie.title,
movieName: movie.title,
coverArt: getCoverArt(movie),
completedAt: record.date,
quality,
instanceName: record._instanceName || null,
arrLink: getRadarrLink(movie),
allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined,
_contentId: record.movieId != null ? record.movieId : null
};
if (isAdmin) {
item.arrRecordId = record.id;
if (outcome === 'failed' && record.data && record.data.message) {
item.failureMessage = record.data.message;
}
}
historyItems.push(item);
} catch (err) {
console.error('[History] Error processing Radarr record:', err.message);
}
}
// Deduplicate: for each content item keep only the most-recent record,
// suppressing failures that were superseded by a successful import.
// Must run before sort so insertion order (newest-first from arr API) is preserved.
const dedupedItems = deduplicateHistoryItems(historyItems, sonarrHistory, radarrHistory);
// Strip internal dedup key before sending to client
for (const item of dedupedItems) delete item._contentId;
// Sort newest first
dedupedItems.sort((a, b) => new Date(b.completedAt) - new Date(a.completedAt));
console.log(`[History] Returning ${dedupedItems.length} items for user ${user.name} (days=${days}, showAll=${showAll})`);
res.json({
user: user.name,
isAdmin,
days,
history: dedupedItems
});
} catch (err) {
console.error('[History] Error:', err.message);
res.status(500).json({ error: 'Failed to fetch history', details: sanitizeError(err) });
}
});
module.exports = router;
+14 -196
View File
@@ -1,238 +1,56 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express');
const axios = require('axios');
const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const sanitizeError = require('../utils/sanitizeError');
const { getWebhookSecret, getSofarrBaseUrl, getRadarrInstances } = require('../utils/config');
// Helper to get first Radarr instance (for notification proxy routes)
function getFirstRadarrInstance() {
const instances = getRadarrInstances();
if (!instances || instances.length === 0) {
return null;
}
return instances[0];
}
router.use(requireAuth);
const RADARR_URL = process.env.RADARR_URL;
const RADARR_API_KEY = process.env.RADARR_API_KEY;
// Get queue
router.get('/queue', async (req, res) => {
try {
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/queue`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
const response = await axios.get(`${RADARR_URL}/api/v3/queue`, {
headers: { 'X-Api-Key': RADARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch Radarr queue', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch Radarr queue', details: error.message });
}
});
// Get history
router.get('/history', async (req, res) => {
try {
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/history`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY },
const response = await axios.get(`${RADARR_URL}/api/v3/history`, {
headers: { 'X-Api-Key': RADARR_API_KEY },
params: { pageSize: req.query.pageSize || 50 }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch Radarr history', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch Radarr history', details: error.message });
}
});
// Get movie details
router.get('/movies/:id', async (req, res) => {
try {
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/movie/${req.params.id}`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
const response = await axios.get(`${RADARR_URL}/api/v3/movie/${req.params.id}`, {
headers: { 'X-Api-Key': RADARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch movie details', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch movie details', details: error.message });
}
});
// Get all movies with tags
router.get('/movies', async (req, res) => {
try {
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/movie`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
const response = await axios.get(`${RADARR_URL}/api/v3/movie`, {
headers: { 'X-Api-Key': RADARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch movies', details: sanitizeError(error) });
}
});
// Notification proxy routes (Phase 3)
// GET /api/radarr/notifications - list all notifications
router.get('/notifications', async (req, res) => {
const instance = getFirstRadarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Radarr not configured' });
}
try {
const response = await axios.get(`${instance.url}/api/v3/notification`, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
console.error('[Radarr] Failed to fetch notifications:', error.message);
res.status(500).json({ error: 'Failed to fetch Radarr notifications', details: sanitizeError(error) });
}
});
// GET /api/radarr/notifications/:id - get specific notification
router.get('/notifications/:id', async (req, res) => {
try {
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/notification/${req.params.id}`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch Radarr notification', details: sanitizeError(error) });
}
});
// POST /api/radarr/notifications - create notification
router.post('/notifications', async (req, res) => {
try {
const response = await axios.post(`${process.env.RADARR_URL}/api/v3/notification`, req.body, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to create Radarr notification', details: sanitizeError(error) });
}
});
// PUT /api/radarr/notifications/:id - update notification
router.put('/notifications/:id', async (req, res) => {
try {
const response = await axios.put(`${process.env.RADARR_URL}/api/v3/notification/${req.params.id}`, req.body, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to update Radarr notification', details: sanitizeError(error) });
}
});
// DELETE /api/radarr/notifications/:id - delete notification
router.delete('/notifications/:id', async (req, res) => {
try {
const response = await axios.delete(`${process.env.RADARR_URL}/api/v3/notification/${req.params.id}`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to delete Radarr notification', details: sanitizeError(error) });
}
});
// POST /api/radarr/notifications/test - test notification
router.post('/notifications/test', async (req, res) => {
const instance = getFirstRadarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Radarr not configured' });
}
try {
const response = await axios.post(`${instance.url}/api/v3/notification/test`, req.body, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
console.error('[Radarr] Failed to test notification:', error.message);
if (error.response) {
console.error('[Radarr] Test response status:', error.response.status);
console.error('[Radarr] Test response data:', error.response.data);
}
res.status(500).json({ error: 'Failed to test Radarr notification', details: sanitizeError(error) });
}
});
// GET /api/radarr/notifications/schema - get notification schema
router.get('/notifications/schema', async (req, res) => {
try {
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/notification/schema`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch Radarr notification schema', details: sanitizeError(error) });
}
});
// POST /api/radarr/notifications/sofarr-webhook - one-click Sofarr webhook setup
router.post('/notifications/sofarr-webhook', async (req, res) => {
const instance = getFirstRadarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Radarr not configured' });
}
try {
const sofarrBaseUrl = getSofarrBaseUrl();
const webhookSecret = getWebhookSecret();
if (!sofarrBaseUrl) {
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
}
if (!webhookSecret) {
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
}
const webhookUrl = `${sofarrBaseUrl}/api/webhook/radarr`;
// Check if Sofarr webhook already exists
const listResponse = await axios.get(`${instance.url}/api/v3/notification`, {
headers: { 'X-Api-Key': instance.apiKey }
});
const existingNotification = listResponse.data.find(n => n.name === 'Sofarr');
const notificationPayload = {
name: 'Sofarr',
implementation: 'Webhook',
configContract: 'WebhookSettings',
fields: [
{ name: 'url', value: webhookUrl },
{ name: 'method', value: 1 },
{ name: 'headers', value: [{ key: 'X-Sofarr-Webhook-Secret', value: webhookSecret }] }
],
onGrab: true,
onDownload: true,
onUpgrade: true,
onImport: true,
onRename: false,
onHealthIssue: false,
onApplicationUpdate: false,
onManualInteractionRequired: false
};
if (existingNotification) {
// Update existing notification
const response = await axios.put(
`${instance.url}/api/v3/notification/${existingNotification.id}`,
{ ...notificationPayload, id: existingNotification.id },
{ headers: { 'X-Api-Key': instance.apiKey } }
);
res.json(response.data);
} else {
// Create new notification
const response = await axios.post(
`${instance.url}/api/v3/notification`,
notificationPayload,
{ headers: { 'X-Api-Key': instance.apiKey } }
);
res.json(response.data);
}
} catch (error) {
console.error('[Radarr] Failed to configure webhook:', error.message);
if (error.response) {
console.error('[Radarr] Response status:', error.response.status);
console.error('[Radarr] Response data:', error.response.data);
}
res.status(500).json({ error: 'Failed to configure Sofarr webhook', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch movies', details: error.message });
}
});
+8 -10
View File
@@ -1,42 +1,40 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express');
const axios = require('axios');
const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const sanitizeError = require('../utils/sanitizeError');
router.use(requireAuth);
const SABNZBD_URL = process.env.SABNZBD_URL;
const SABNZBD_API_KEY = process.env.SABNZBD_API_KEY;
// Get current queue
router.get('/queue', async (req, res) => {
try {
const response = await axios.get(`${process.env.SABNZBD_URL}/api`, {
const response = await axios.get(`${SABNZBD_URL}/api`, {
params: {
mode: 'queue',
apikey: process.env.SABNZBD_API_KEY,
apikey: SABNZBD_API_KEY,
output: 'json'
}
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch SABnzbd queue', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch SABnzbd queue', details: error.message });
}
});
// Get history
router.get('/history', async (req, res) => {
try {
const response = await axios.get(`${process.env.SABNZBD_URL}/api`, {
const response = await axios.get(`${SABNZBD_URL}/api`, {
params: {
mode: 'history',
apikey: process.env.SABNZBD_API_KEY,
apikey: SABNZBD_API_KEY,
output: 'json',
limit: req.query.limit || 50
}
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch SABnzbd history', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch SABnzbd history', details: error.message });
}
});
+14 -196
View File
@@ -1,238 +1,56 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express');
const axios = require('axios');
const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const sanitizeError = require('../utils/sanitizeError');
const { getWebhookSecret, getSofarrBaseUrl, getSonarrInstances } = require('../utils/config');
// Helper to get first Sonarr instance (for notification proxy routes)
function getFirstSonarrInstance() {
const instances = getSonarrInstances();
if (!instances || instances.length === 0) {
return null;
}
return instances[0];
}
router.use(requireAuth);
const SONARR_URL = process.env.SONARR_URL;
const SONARR_API_KEY = process.env.SONARR_API_KEY;
// Get queue
router.get('/queue', async (req, res) => {
try {
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/queue`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
const response = await axios.get(`${SONARR_URL}/api/v3/queue`, {
headers: { 'X-Api-Key': SONARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch Sonarr queue', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch Sonarr queue', details: error.message });
}
});
// Get history
router.get('/history', async (req, res) => {
try {
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/history`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY },
const response = await axios.get(`${SONARR_URL}/api/v3/history`, {
headers: { 'X-Api-Key': SONARR_API_KEY },
params: { pageSize: req.query.pageSize || 50 }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch Sonarr history', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch Sonarr history', details: error.message });
}
});
// Get series details
router.get('/series/:id', async (req, res) => {
try {
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/series/${req.params.id}`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
const response = await axios.get(`${SONARR_URL}/api/v3/series/${req.params.id}`, {
headers: { 'X-Api-Key': SONARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch series details', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch series details', details: error.message });
}
});
// Get all series with tags
router.get('/series', async (req, res) => {
try {
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/series`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
const response = await axios.get(`${SONARR_URL}/api/v3/series`, {
headers: { 'X-Api-Key': SONARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch series', details: sanitizeError(error) });
}
});
// Notification proxy routes (Phase 3)
// GET /api/sonarr/notifications - list all notifications
router.get('/notifications', async (req, res) => {
const instance = getFirstSonarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Sonarr not configured' });
}
try {
const response = await axios.get(`${instance.url}/api/v3/notification`, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
console.error('[Sonarr] Failed to fetch notifications:', error.message);
res.status(500).json({ error: 'Failed to fetch Sonarr notifications', details: sanitizeError(error) });
}
});
// GET /api/sonarr/notifications/:id - get specific notification
router.get('/notifications/:id', async (req, res) => {
try {
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/notification/${req.params.id}`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch Sonarr notification', details: sanitizeError(error) });
}
});
// POST /api/sonarr/notifications - create notification
router.post('/notifications', async (req, res) => {
try {
const response = await axios.post(`${process.env.SONARR_URL}/api/v3/notification`, req.body, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to create Sonarr notification', details: sanitizeError(error) });
}
});
// PUT /api/sonarr/notifications/:id - update notification
router.put('/notifications/:id', async (req, res) => {
try {
const response = await axios.put(`${process.env.SONARR_URL}/api/v3/notification/${req.params.id}`, req.body, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to update Sonarr notification', details: sanitizeError(error) });
}
});
// DELETE /api/sonarr/notifications/:id - delete notification
router.delete('/notifications/:id', async (req, res) => {
try {
const response = await axios.delete(`${process.env.SONARR_URL}/api/v3/notification/${req.params.id}`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to delete Sonarr notification', details: sanitizeError(error) });
}
});
// POST /api/sonarr/notifications/test - test notification
router.post('/notifications/test', async (req, res) => {
const instance = getFirstSonarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Sonarr not configured' });
}
try {
const response = await axios.post(`${instance.url}/api/v3/notification/test`, req.body, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
console.error('[Sonarr] Failed to test notification:', error.message);
if (error.response) {
console.error('[Sonarr] Test response status:', error.response.status);
console.error('[Sonarr] Test response data:', error.response.data);
}
res.status(500).json({ error: 'Failed to test Sonarr notification', details: sanitizeError(error) });
}
});
// GET /api/sonarr/notifications/schema - get notification schema
router.get('/notifications/schema', async (req, res) => {
try {
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/notification/schema`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch Sonarr notification schema', details: sanitizeError(error) });
}
});
// POST /api/sonarr/notifications/sofarr-webhook - one-click Sofarr webhook setup
router.post('/notifications/sofarr-webhook', async (req, res) => {
const instance = getFirstSonarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Sonarr not configured' });
}
try {
const sofarrBaseUrl = getSofarrBaseUrl();
const webhookSecret = getWebhookSecret();
if (!sofarrBaseUrl) {
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
}
if (!webhookSecret) {
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
}
const webhookUrl = `${sofarrBaseUrl}/api/webhook/sonarr`;
// Check if Sofarr webhook already exists
const listResponse = await axios.get(`${instance.url}/api/v3/notification`, {
headers: { 'X-Api-Key': instance.apiKey }
});
const existingNotification = listResponse.data.find(n => n.name === 'Sofarr');
const notificationPayload = {
name: 'Sofarr',
implementation: 'Webhook',
configContract: 'WebhookSettings',
fields: [
{ name: 'url', value: webhookUrl },
{ name: 'method', value: 1 },
{ name: 'headers', value: [{ key: 'X-Sofarr-Webhook-Secret', value: webhookSecret }] }
],
onGrab: true,
onDownload: true,
onUpgrade: true,
onImport: true,
onRename: false,
onHealthIssue: false,
onApplicationUpdate: false,
onManualInteractionRequired: false
};
if (existingNotification) {
// Update existing notification
const response = await axios.put(
`${instance.url}/api/v3/notification/${existingNotification.id}`,
{ ...notificationPayload, id: existingNotification.id },
{ headers: { 'X-Api-Key': instance.apiKey } }
);
res.json(response.data);
} else {
// Create new notification
const response = await axios.post(
`${instance.url}/api/v3/notification`,
notificationPayload,
{ headers: { 'X-Api-Key': instance.apiKey } }
);
res.json(response.data);
}
} catch (error) {
console.error('[Sonarr] Failed to configure webhook:', error.message);
if (error.response) {
console.error('[Sonarr] Response status:', error.response.status);
console.error('[Sonarr] Response data:', error.response.data);
}
res.status(500).json({ error: 'Failed to configure Sofarr webhook', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch series', details: error.message });
}
});
-325
View File
@@ -1,325 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express');
const rateLimit = require('express-rate-limit');
const { logToFile } = require('../utils/logger');
const { getWebhookSecret, getSonarrInstances, getRadarrInstances } = require('../utils/config');
const cache = require('../utils/cache');
const arrRetrieverRegistry = require('../utils/arrRetrievers');
const { pollAllServices, POLL_INTERVAL, POLLING_ENABLED } = require('../utils/poller');
const router = express.Router();
// Dedicated rate limiter for webhook endpoints — stricter than the global API limiter.
// Sonarr/Radarr send at most one event per action; 60/min per IP is generous.
// In tests, SKIP_RATE_LIMIT=1 raises the ceiling to effectively unlimited.
const webhookLimiter = rateLimit({
windowMs: 60 * 1000,
max: process.env.SKIP_RATE_LIMIT ? Number.MAX_SAFE_INTEGER : 60,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many webhook requests' }
});
// Valid *arr eventType strings — used for strict input validation.
const VALID_EVENT_TYPES = new Set([
'Test',
'Grab', 'Download', 'DownloadFailed', 'ManualInteractionRequired',
'DownloadFolderImported', 'ImportFailed',
'EpisodeFileRenamed', 'MovieFileRenamed', 'EpisodeFileRenamedBySeries',
'Rename', 'SeriesAdd', 'SeriesDelete', 'MovieAdd', 'MovieDelete',
'MovieFileDelete', 'Health', 'ApplicationUpdate', 'HealthRestored'
]);
// Replay protection — cache recently-seen (eventType+instanceName+timestamp) keys.
// *arr sends a `date` field on every event; we use it as the replay key component.
// TTL = 5 minutes; an event replayed after that window is considered fresh.
const REPLAY_WINDOW_MS = 5 * 60 * 1000;
const recentEvents = new Map();
function pruneReplayCache() {
const cutoff = Date.now() - REPLAY_WINDOW_MS;
for (const [key, ts] of recentEvents) {
if (ts < cutoff) recentEvents.delete(key);
}
}
function isReplay(eventType, instanceName, eventDate) {
if (!eventDate) return false;
pruneReplayCache();
const key = `${eventType}:${instanceName || ''}:${eventDate}`;
if (recentEvents.has(key)) return true;
recentEvents.set(key, Date.now());
return false;
}
// Cache TTL mirrors poller.js logic: 3x poll interval when active, 30s when on-demand
const CACHE_TTL = POLLING_ENABLED ? POLL_INTERVAL * 3 : 30000;
// Event classification — determines which cache keys to refresh
const QUEUE_EVENTS = new Set([
'Grab',
'Download',
'DownloadFailed',
'ManualInteractionRequired'
]);
const HISTORY_EVENTS = new Set([
'DownloadFolderImported',
'ImportFailed',
'EpisodeFileRenamed',
'MovieFileRenamed',
'EpisodeFileRenamedBySeries'
]);
/**
* Validate webhook secret from the X-Sofarr-Webhook-Secret header
* @param {Object} req - Express request object
* @returns {boolean} True if secret is valid, false otherwise
*/
function validateWebhookSecret(req) {
const expectedSecret = getWebhookSecret();
const providedSecret = req.get('X-Sofarr-Webhook-Secret');
if (!expectedSecret) {
logToFile('[Webhook] WARNING: SOFARR_WEBHOOK_SECRET not configured, rejecting webhook');
return false;
}
if (!providedSecret) {
logToFile('[Webhook] WARNING: Missing X-Sofarr-Webhook-Secret header');
return false;
}
if (providedSecret !== expectedSecret) {
logToFile('[Webhook] WARNING: Invalid webhook secret provided');
return false;
}
return true;
}
/**
* Process a webhook event by refreshing the affected cache and broadcasting SSE.
* This is a fire-and-forget background task — callers must respond to the webhook
* sender before awaiting this function.
*
* Phase 2: lightweight refresh via arrRetrieverRegistry + cache update + SSE broadcast.
*
* @param {string} serviceType - 'sonarr' or 'radarr'
* @param {string} eventType - the eventType from the *arr webhook payload
*/
async function processWebhookEvent(serviceType, eventType) {
const affectsQueue = QUEUE_EVENTS.has(eventType);
const affectsHistory = HISTORY_EVENTS.has(eventType);
if (!affectsQueue && !affectsHistory) {
logToFile(`[Webhook] Event ${eventType} does not affect queue or history, skipping refresh`);
return;
}
logToFile(`[Webhook] ${serviceType} event "${eventType}" → queue=${affectsQueue}, history=${affectsHistory}`);
// Ensure retrievers are initialized (idempotent)
await arrRetrieverRegistry.initialize();
if (serviceType === 'sonarr') {
const sonarrInstances = getSonarrInstances();
if (affectsQueue) {
const queuesByType = await arrRetrieverRegistry.getQueuesByType();
const sonarrQueues = queuesByType.sonarr || [];
cache.set('poll:sonarr-queue', {
records: sonarrQueues.flatMap(q => {
const inst = sonarrInstances.find(i => i.id === q.instance);
const url = inst ? inst.url : null;
const key = inst ? inst.apiKey : null;
return (q.data.records || []).map(r => {
if (r.series) r.series._instanceUrl = url;
r._instanceUrl = url;
r._instanceKey = key;
return r;
});
})
}, CACHE_TTL);
logToFile(`[Webhook] Refreshed poll:sonarr-queue (${sonarrQueues.length} instance(s))`);
}
if (affectsHistory) {
const historyByType = await arrRetrieverRegistry.getHistoryByType({ pageSize: 10 });
const sonarrHistories = historyByType.sonarr || [];
cache.set('poll:sonarr-history', {
records: sonarrHistories.flatMap(h => h.data.records || [])
}, CACHE_TTL);
logToFile(`[Webhook] Refreshed poll:sonarr-history (${sonarrHistories.length} instance(s))`);
}
} else if (serviceType === 'radarr') {
const radarrInstances = getRadarrInstances();
if (affectsQueue) {
const queuesByType = await arrRetrieverRegistry.getQueuesByType();
const radarrQueues = queuesByType.radarr || [];
cache.set('poll:radarr-queue', {
records: radarrQueues.flatMap(q => {
const inst = radarrInstances.find(i => i.id === q.instance);
const url = inst ? inst.url : null;
const key = inst ? inst.apiKey : null;
return (q.data.records || []).map(r => {
if (r.movie) r.movie._instanceUrl = url;
r._instanceUrl = url;
r._instanceKey = key;
return r;
});
})
}, CACHE_TTL);
logToFile(`[Webhook] Refreshed poll:radarr-queue (${radarrQueues.length} instance(s))`);
}
if (affectsHistory) {
const historyByType = await arrRetrieverRegistry.getHistoryByType({ pageSize: 10 });
const radarrHistories = historyByType.radarr || [];
cache.set('poll:radarr-history', {
records: radarrHistories.flatMap(h => h.data.records || [])
}, CACHE_TTL);
logToFile(`[Webhook] Refreshed poll:radarr-history (${radarrHistories.length} instance(s))`);
}
}
// Broadcast to all SSE subscribers using the same mechanism poller.js uses.
// pollAllServices() refreshes all data, updates every cache key, and then
// iterates pollSubscribers to push fresh payloads to every open SSE connection.
// If a poll is already in progress this call is a no-op, but the cache keys
// above were already updated so the next broadcast (or dashboard request)
// will see fresh data.
logToFile('[Webhook] Triggering SSE broadcast via pollAllServices()');
await pollAllServices();
}
/**
* Validate and sanitize the incoming webhook payload.
* Returns { valid, eventType, instanceName, eventDate } or { valid: false, reason }.
*/
function validatePayload(body) {
if (!body || typeof body !== 'object' || Array.isArray(body)) {
return { valid: false, reason: 'Payload must be a JSON object' };
}
const { eventType, instanceName } = body;
if (typeof eventType !== 'string' || eventType.length === 0 || eventType.length > 64) {
return { valid: false, reason: 'eventType must be a non-empty string (max 64 chars)' };
}
if (!VALID_EVENT_TYPES.has(eventType)) {
return { valid: false, reason: `Unknown eventType: ${eventType}` };
}
if (instanceName !== undefined && typeof instanceName !== 'string') {
return { valid: false, reason: 'instanceName must be a string if provided' };
}
const eventDate = body.date || null;
return { valid: true, eventType, instanceName: instanceName || null, eventDate };
}
/**
* POST /api/webhook/sonarr
* Receives webhook events from Sonarr instances.
* Validates the secret, logs the event, refreshes cache, broadcasts SSE, and returns 200.
*
* Phase 2: integrated with PALDRA cache + SSE for real-time dashboard updates.
* Phase 6: rate limiting, input validation, replay protection.
*/
router.post('/sonarr', webhookLimiter, (req, res) => {
if (!validateWebhookSecret(req)) {
return res.status(401).json({ error: 'Unauthorized' });
}
const validation = validatePayload(req.body);
if (!validation.valid) {
logToFile(`[Webhook] Sonarr payload rejected: ${validation.reason}`);
return res.status(400).json({ error: validation.reason });
}
const { eventType, instanceName, eventDate } = validation;
if (isReplay(eventType, instanceName, eventDate)) {
logToFile(`[Webhook] Sonarr duplicate event ignored: ${eventType} @ ${eventDate}`);
return res.status(200).json({ received: true, duplicate: true });
}
try {
logToFile(`[Webhook] Sonarr event received - Type: ${eventType}, Instance: ${instanceName || 'unknown'}`);
logToFile(`[Webhook] Sonarr payload: ${JSON.stringify(req.body)}`);
// Phase 5.1: update webhook metrics for polling optimization
// Note: instanceName from webhook is often generic (e.g., "Sonarr"), not the configured name
// Update metrics for all Sonarr instances since we can't reliably match
const sonarrInstances = getSonarrInstances();
if (sonarrInstances.length > 0) {
for (const inst of sonarrInstances) {
cache.updateWebhookMetrics(inst.url);
}
logToFile(`[Webhook] Updated metrics for ${sonarrInstances.length} Sonarr instance(s)`);
}
// Phase 2: background cache refresh + SSE broadcast (fire-and-forget)
processWebhookEvent('sonarr', eventType).catch(err => {
logToFile(`[Webhook] Sonarr background refresh error: ${err.message}`);
});
res.status(200).json({ received: true });
} catch (error) {
logToFile(`[Webhook] Sonarr error: ${error.message}`);
res.status(200).json({ received: true });
}
});
/**
* POST /api/webhook/radarr
* Receives webhook events from Radarr instances.
* Validates the secret, logs the event, refreshes cache, broadcasts SSE, and returns 200.
*
* Phase 2: integrated with PALDRA cache + SSE for real-time dashboard updates.
* Phase 6: rate limiting, input validation, replay protection.
*/
router.post('/radarr', webhookLimiter, (req, res) => {
if (!validateWebhookSecret(req)) {
return res.status(401).json({ error: 'Unauthorized' });
}
const validation = validatePayload(req.body);
if (!validation.valid) {
logToFile(`[Webhook] Radarr payload rejected: ${validation.reason}`);
return res.status(400).json({ error: validation.reason });
}
const { eventType, instanceName, eventDate } = validation;
if (isReplay(eventType, instanceName, eventDate)) {
logToFile(`[Webhook] Radarr duplicate event ignored: ${eventType} @ ${eventDate}`);
return res.status(200).json({ received: true, duplicate: true });
}
try {
logToFile(`[Webhook] Radarr event received - Type: ${eventType}, Instance: ${instanceName || 'unknown'}`);
logToFile(`[Webhook] Radarr payload: ${JSON.stringify(req.body)}`);
// Phase 5.1: update webhook metrics for polling optimization
// Note: instanceName from webhook is often generic (e.g., "Radarr"), not the configured name
// Update metrics for all Radarr instances since we can't reliably match
const radarrInstances = getRadarrInstances();
if (radarrInstances.length > 0) {
for (const inst of radarrInstances) {
cache.updateWebhookMetrics(inst.url);
}
logToFile(`[Webhook] Updated metrics for ${radarrInstances.length} Radarr instance(s)`);
}
// Phase 2: background cache refresh + SSE broadcast (fire-and-forget)
processWebhookEvent('radarr', eventType).catch(err => {
logToFile(`[Webhook] Radarr background refresh error: ${err.message}`);
});
res.status(200).json({ received: true });
} catch (error) {
logToFile(`[Webhook] Radarr error: ${error.message}`);
res.status(200).json({ received: true });
}
});
module.exports = router;
-308
View File
@@ -1,308 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const { logToFile } = require('./logger');
const {
getSonarrInstances,
getRadarrInstances
} = require('./config');
// Import retriever classes
const PollingSonarrRetriever = require('../clients/PollingSonarrRetriever');
const PollingRadarrRetriever = require('../clients/PollingRadarrRetriever');
// Retriever type mapping
const retrieverClasses = {
sonarr: PollingSonarrRetriever,
radarr: PollingRadarrRetriever
};
/**
* Singleton registry for *arr data retrievers
*/
const arrRetrieverRegistry = {
retrievers: new Map(),
initialized: false,
/**
* Initialize all configured *arr retrievers
*/
async initialize() {
if (this.initialized) {
return;
}
logToFile('[ArrRetrieverRegistry] Initializing *arr retrievers...');
// Get all instance configurations
const sonarrInstances = getSonarrInstances();
const radarrInstances = getRadarrInstances();
// Create retriever instances
const instanceConfigs = [
...sonarrInstances.map(inst => ({ ...inst, type: 'sonarr' })),
...radarrInstances.map(inst => ({ ...inst, type: 'radarr' }))
];
for (const config of instanceConfigs) {
try {
const RetrieverClass = retrieverClasses[config.type];
if (!RetrieverClass) {
logToFile(`[ArrRetrieverRegistry] Unknown retriever type: ${config.type}`);
continue;
}
const retriever = new RetrieverClass(config);
const uniqueKey = `${config.type}:${config.id}`;
this.retrievers.set(uniqueKey, retriever);
logToFile(`[ArrRetrieverRegistry] Created ${config.type} retriever: ${config.name} (${uniqueKey})`);
} catch (error) {
logToFile(`[ArrRetrieverRegistry] Failed to create retriever ${config.id}: ${error.message}`);
}
}
this.initialized = true;
logToFile(`[ArrRetrieverRegistry] Initialized ${this.retrievers.size} *arr retrievers`);
},
/**
* Get all registered retrievers
* @returns {Array<ArrRetriever>} Array of retriever instances
*/
getAllRetrievers() {
return Array.from(this.retrievers.values());
},
/**
* Get retriever by instance ID
* @param {string} instanceId - The instance ID
* @returns {ArrRetriever|null} Retriever instance or null if not found
*/
getRetriever(instanceId) {
return this.retrievers.get(instanceId) || null;
},
/**
* Get retrievers by type
* @param {string} type - Retriever type ('sonarr', 'radarr')
* @returns {Array<ArrRetriever>} Array of retriever instances
*/
getRetrieversByType(type) {
return this.getAllRetrievers().filter(retriever => retriever.getRetrieverType() === type);
},
/**
* Get tags from all retrievers
* @returns {Promise<Array<Object>>} Array of tag results with instance info
*/
async getAllTags() {
const retrievers = this.getAllRetrievers();
if (retrievers.length === 0) {
return [];
}
// Fetch tags from all retrievers in parallel
const results = await Promise.allSettled(
retrievers.map(async (retriever) => {
try {
const tags = await retriever.getTags();
logToFile(`[ArrRetrieverRegistry] ${retriever.name}: ${tags.length} tags`);
return { instance: retriever.getInstanceId(), data: tags };
} catch (error) {
logToFile(`[ArrRetrieverRegistry] Error fetching tags from ${retriever.name}: ${error.message}`);
return { instance: retriever.getInstanceId(), data: [] };
}
})
);
return results
.filter(result => result.status === 'fulfilled')
.map(result => result.value);
},
/**
* Get queue from all retrievers
* @returns {Promise<Array<Object>>} Array of queue results with instance info
*/
async getAllQueues() {
const retrievers = this.getAllRetrievers();
if (retrievers.length === 0) {
return [];
}
// Fetch queues from all retrievers in parallel
const results = await Promise.allSettled(
retrievers.map(async (retriever) => {
try {
const queue = await retriever.getQueue();
logToFile(`[ArrRetrieverRegistry] ${retriever.name}: ${(queue.records || []).length} queue items`);
return { instance: retriever.getInstanceId(), data: queue };
} catch (error) {
logToFile(`[ArrRetrieverRegistry] Error fetching queue from ${retriever.name}: ${error.message}`);
return { instance: retriever.getInstanceId(), data: { records: [] } };
}
})
);
return results
.filter(result => result.status === 'fulfilled')
.map(result => result.value);
},
/**
* Get history from all retrievers
* @param {Object} options - Optional parameters for history fetch
* @returns {Promise<Array<Object>>} Array of history results with instance info
*/
async getAllHistory(options = {}) {
const retrievers = this.getAllRetrievers();
if (retrievers.length === 0) {
return [];
}
// Fetch history from all retrievers in parallel
const results = await Promise.allSettled(
retrievers.map(async (retriever) => {
try {
const history = await retriever.getHistory(options);
logToFile(`[ArrRetrieverRegistry] ${retriever.name}: ${(history.records || []).length} history records`);
return { instance: retriever.getInstanceId(), data: history };
} catch (error) {
logToFile(`[ArrRetrieverRegistry] Error fetching history from ${retriever.name}: ${error.message}`);
return { instance: retriever.getInstanceId(), data: { records: [] } };
}
})
);
return results
.filter(result => result.status === 'fulfilled')
.map(result => result.value);
},
/**
* Get tags grouped by retriever type
* @returns {Promise<Object>} Tags grouped by retriever type (array of { instance, data } objects)
*/
async getTagsByType() {
const sonarrRetrievers = this.getRetrieversByType('sonarr');
const radarrRetrievers = this.getRetrieversByType('radarr');
const sonarrTags = await Promise.allSettled(
sonarrRetrievers.map(async (retriever) => {
try {
const tags = await retriever.getTags();
return { instance: retriever.getInstanceId(), data: tags };
} catch (error) {
logToFile(`[ArrRetrieverRegistry] Error fetching tags from ${retriever.name}: ${error.message}`);
return { instance: retriever.getInstanceId(), data: [] };
}
})
);
const radarrTags = await Promise.allSettled(
radarrRetrievers.map(async (retriever) => {
try {
const tags = await retriever.getTags();
return { instance: retriever.getInstanceId(), data: tags };
} catch (error) {
logToFile(`[ArrRetrieverRegistry] Error fetching tags from ${retriever.name}: ${error.message}`);
return { instance: retriever.getInstanceId(), data: [] };
}
})
);
return {
sonarr: sonarrTags
.filter(result => result.status === 'fulfilled')
.map(result => result.value),
radarr: radarrTags
.filter(result => result.status === 'fulfilled')
.map(result => result.value)
};
},
/**
* Get queue grouped by retriever type
* @returns {Promise<Object>} Queue grouped by retriever type
*/
async getQueuesByType() {
const sonarrRetrievers = this.getRetrieversByType('sonarr');
const radarrRetrievers = this.getRetrieversByType('radarr');
const sonarrQueues = await Promise.allSettled(
sonarrRetrievers.map(async (retriever) => {
try {
const queue = await retriever.getQueue();
return { instance: retriever.getInstanceId(), data: queue };
} catch (error) {
logToFile(`[ArrRetrieverRegistry] Error fetching queue from ${retriever.name}: ${error.message}`);
return { instance: retriever.getInstanceId(), data: { records: [] } };
}
})
);
const radarrQueues = await Promise.allSettled(
radarrRetrievers.map(async (retriever) => {
try {
const queue = await retriever.getQueue();
return { instance: retriever.getInstanceId(), data: queue };
} catch (error) {
logToFile(`[ArrRetrieverRegistry] Error fetching queue from ${retriever.name}: ${error.message}`);
return { instance: retriever.getInstanceId(), data: { records: [] } };
}
})
);
return {
sonarr: sonarrQueues
.filter(result => result.status === 'fulfilled')
.map(result => result.value),
radarr: radarrQueues
.filter(result => result.status === 'fulfilled')
.map(result => result.value)
};
},
/**
* Get history grouped by retriever type
* @param {Object} options - Optional parameters for history fetch
* @returns {Promise<Object>} History grouped by retriever type
*/
async getHistoryByType(options = {}) {
const sonarrRetrievers = this.getRetrieversByType('sonarr');
const radarrRetrievers = this.getRetrieversByType('radarr');
const sonarrHistory = await Promise.allSettled(
sonarrRetrievers.map(async (retriever) => {
try {
const history = await retriever.getHistory(options);
return { instance: retriever.getInstanceId(), data: history };
} catch (error) {
logToFile(`[ArrRetrieverRegistry] Error fetching history from ${retriever.name}: ${error.message}`);
return { instance: retriever.getInstanceId(), data: { records: [] } };
}
})
);
const radarrHistory = await Promise.allSettled(
radarrRetrievers.map(async (retriever) => {
try {
const history = await retriever.getHistory(options);
return { instance: retriever.getInstanceId(), data: history };
} catch (error) {
logToFile(`[ArrRetrieverRegistry] Error fetching history from ${retriever.name}: ${error.message}`);
return { instance: retriever.getInstanceId(), data: { records: [] } };
}
})
);
return {
sonarr: sonarrHistory
.filter(result => result.status === 'fulfilled')
.map(result => result.value),
radarr: radarrHistory
.filter(result => result.status === 'fulfilled')
.map(result => result.value)
};
}
};
module.exports = arrRetrieverRegistry;
-135
View File
@@ -1,135 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const { logToFile } = require('./logger');
class MemoryCache {
constructor() {
this.store = new Map();
}
get(key) {
const entry = this.store.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.store.delete(key);
return null;
}
return entry.value;
}
set(key, value, ttlMs) {
this.store.set(key, {
value,
expiresAt: Date.now() + ttlMs
});
}
invalidate(key) {
this.store.delete(key);
}
clear() {
this.store.clear();
}
getStats() {
const now = Date.now();
const entries = [];
let totalSize = 0;
for (const [key, entry] of this.store.entries()) {
// Maps must be converted before JSON.stringify (which renders them as "{}")
const serializable = entry.value instanceof Map ? Object.fromEntries(entry.value) : entry.value;
const json = JSON.stringify(serializable);
const sizeBytes = Buffer.byteLength(json, 'utf8');
totalSize += sizeBytes;
const ttlRemaining = Math.max(0, entry.expiresAt - now);
const expired = now > entry.expiresAt;
let itemCount = null;
if (entry.value instanceof Map) {
itemCount = entry.value.size;
} else if (Array.isArray(entry.value)) {
itemCount = entry.value.length;
} else if (entry.value && typeof entry.value === 'object') {
if (Array.isArray(entry.value.records)) itemCount = entry.value.records.length;
else if (Array.isArray(entry.value.slots)) itemCount = entry.value.slots.length;
}
entries.push({
key,
sizeBytes,
itemCount,
ttlRemainingMs: ttlRemaining,
expired
});
}
return {
entryCount: this.store.size,
totalSizeBytes: totalSize,
entries
};
}
}
const cache = new MemoryCache();
// Webhook metrics for polling optimization
// These are stored separately from regular cache entries
const webhookMetrics = {
// Per-instance metrics: key = instance URL, value = { lastWebhookTimestamp, eventsReceived, pollsSkipped }
instances: new Map(),
// Global metrics
lastGlobalWebhookTimestamp: null,
totalWebhookEventsReceived: 0
};
function getWebhookMetrics(instanceUrl) {
if (!instanceUrl) return null;
return webhookMetrics.instances.get(instanceUrl) || {
lastWebhookTimestamp: null,
eventsReceived: 0,
pollsSkipped: 0
};
}
function updateWebhookMetrics(instanceUrl) {
const now = Date.now();
webhookMetrics.lastGlobalWebhookTimestamp = now;
webhookMetrics.totalWebhookEventsReceived++;
if (instanceUrl) {
const metrics = webhookMetrics.instances.get(instanceUrl) || {
lastWebhookTimestamp: null,
eventsReceived: 0,
pollsSkipped: 0
};
metrics.lastWebhookTimestamp = now;
metrics.eventsReceived++;
webhookMetrics.instances.set(instanceUrl, metrics);
}
}
function incrementPollsSkipped(instanceUrl) {
if (instanceUrl) {
const metrics = webhookMetrics.instances.get(instanceUrl) || {
lastWebhookTimestamp: null,
eventsReceived: 0,
pollsSkipped: 0
};
metrics.pollsSkipped++;
webhookMetrics.instances.set(instanceUrl, metrics);
}
}
function getGlobalWebhookMetrics() {
return {
lastGlobalWebhookTimestamp: webhookMetrics.lastGlobalWebhookTimestamp,
totalWebhookEventsReceived: webhookMetrics.totalWebhookEventsReceived,
instances: Object.fromEntries(webhookMetrics.instances)
};
}
module.exports = cache;
module.exports.getWebhookMetrics = getWebhookMetrics;
module.exports.updateWebhookMetrics = updateWebhookMetrics;
module.exports.incrementPollsSkipped = incrementPollsSkipped;
module.exports.getGlobalWebhookMetrics = getGlobalWebhookMetrics;
+5 -63
View File
@@ -1,28 +1,5 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const { logToFile } = require('./logger');
// Validate that a configured service URL is well-formed and uses http(s).
// Emits a warning (never throws) so a misconfigured instance degrades
// gracefully rather than crashing the whole server.
function validateInstanceUrl(url, instanceId) {
if (!url || typeof url !== 'string') {
logToFile(`[Config] WARNING: instance "${instanceId}" has no URL configured`);
return false;
}
let parsed;
try {
parsed = new URL(url);
} catch {
logToFile(`[Config] WARNING: instance "${instanceId}" has an invalid URL: "${url}"`);
return false;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
logToFile(`[Config] WARNING: instance "${instanceId}" URL must use http or https, got "${parsed.protocol}"`);
return false;
}
return true;
}
function parseInstances(envVar, legacyUrl, legacyKey, legacyUsername, legacyPassword) {
// Try to parse JSON array format first
if (envVar) {
@@ -32,11 +9,10 @@ function parseInstances(envVar, legacyUrl, legacyKey, legacyUsername, legacyPass
const instances = JSON.parse(cleaned);
if (Array.isArray(instances) && instances.length > 0) {
logToFile(`[Config] Parsed ${instances.length} instances from JSON array`);
return instances.map((inst, idx) => {
const id = inst.name || `instance-${idx + 1}`;
validateInstanceUrl(inst.url, id);
return { ...inst, id };
});
return instances.map((inst, idx) => ({
...inst,
id: inst.name || `instance-${idx + 1}`
}));
}
} catch (err) {
logToFile(`[Config] Failed to parse JSON array: ${err.message}`);
@@ -46,7 +22,6 @@ function parseInstances(envVar, legacyUrl, legacyKey, legacyUsername, legacyPass
// Fall back to legacy single-instance format
if (legacyUrl && legacyKey) {
logToFile(`[Config] Using legacy single-instance format`);
validateInstanceUrl(legacyUrl, 'default');
return [{
id: 'default',
name: 'Default',
@@ -94,43 +69,10 @@ function getQbittorrentInstances() {
);
}
function getTransmissionInstances() {
return parseInstances(
process.env.TRANSMISSION_INSTANCES,
process.env.TRANSMISSION_URL,
null, // no apiKey for Transmission
process.env.TRANSMISSION_USERNAME,
process.env.TRANSMISSION_PASSWORD
);
}
function getRtorrentInstances() {
return parseInstances(
process.env.RTORRENT_INSTANCES,
process.env.RTORRENT_URL,
null, // no apiKey for rtorrent
process.env.RTORRENT_USERNAME,
process.env.RTORRENT_PASSWORD
);
}
function getWebhookSecret() {
return process.env.SOFARR_WEBHOOK_SECRET || '';
}
function getSofarrBaseUrl() {
return process.env.SOFARR_BASE_URL || '';
}
module.exports = {
getSABnzbdInstances,
getSonarrInstances,
getRadarrInstances,
getQbittorrentInstances,
getTransmissionInstances,
getRtorrentInstances,
getWebhookSecret,
getSofarrBaseUrl,
parseInstances,
validateInstanceUrl
parseInstances
};
-255
View File
@@ -1,255 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const { logToFile } = require('./logger');
const {
getSABnzbdInstances,
getQbittorrentInstances,
getTransmissionInstances,
getRtorrentInstances
} = require('./config');
// Import client classes
const SABnzbdClient = require('../clients/SABnzbdClient');
const QBittorrentClient = require('../clients/QBittorrentClient');
const TransmissionClient = require('../clients/TransmissionClient');
const RTorrentClient = require('../clients/RTorrentClient');
// Client type mapping
const clientClasses = {
sabnzbd: SABnzbdClient,
qbittorrent: QBittorrentClient,
transmission: TransmissionClient,
rtorrent: RTorrentClient
};
/**
* Registry and factory for download clients
*/
class DownloadClientRegistry {
constructor() {
this.clients = new Map();
this.initialized = false;
}
/**
* Initialize all configured download clients
*/
async initialize() {
if (this.initialized) {
return;
}
logToFile('[DownloadClientRegistry] Initializing download clients...');
// Get all instance configurations
const sabnzbdInstances = getSABnzbdInstances();
const qbittorrentInstances = getQbittorrentInstances();
const transmissionInstances = getTransmissionInstances();
const rtorrentInstances = getRtorrentInstances();
// Create client instances
const instanceConfigs = [
...sabnzbdInstances.map(inst => ({ ...inst, type: 'sabnzbd' })),
...qbittorrentInstances.map(inst => ({ ...inst, type: 'qbittorrent' })),
...transmissionInstances.map(inst => ({ ...inst, type: 'transmission' })),
...rtorrentInstances.map(inst => ({ ...inst, type: 'rtorrent' }))
];
for (const config of instanceConfigs) {
try {
const ClientClass = clientClasses[config.type];
if (!ClientClass) {
logToFile(`[DownloadClientRegistry] Unknown client type: ${config.type}`);
continue;
}
const client = new ClientClass(config);
const uniqueKey = `${config.type}:${config.id}`;
this.clients.set(uniqueKey, client);
logToFile(`[DownloadClientRegistry] Created ${config.type} client: ${config.name} (${uniqueKey})`);
} catch (error) {
logToFile(`[DownloadClientRegistry] Failed to create client ${config.id}: ${error.message}`);
}
}
this.initialized = true;
logToFile(`[DownloadClientRegistry] Initialized ${this.clients.size} download clients`);
}
/**
* Get all registered clients
* @returns {Array<DownloadClient>} Array of client instances
*/
getAllClients() {
return Array.from(this.clients.values());
}
/**
* Get client by instance ID
* @param {string} instanceId - The instance ID
* @returns {DownloadClient|null} Client instance or null if not found
*/
getClient(instanceId) {
return this.clients.get(instanceId) || null;
}
/**
* Get clients by type
* @param {string} type - Client type ('sabnzbd', 'qbittorrent', 'transmission')
* @returns {Array<DownloadClient>} Array of client instances
*/
getClientsByType(type) {
return this.getAllClients().filter(client => client.getClientType() === type);
}
/**
* Get active downloads from all clients
* @returns {Promise<Array<NormalizedDownload>>} Array of all downloads
*/
async getAllDownloads() {
const clients = this.getAllClients();
if (clients.length === 0) {
return [];
}
// Reset fallback flags for qBittorrent clients
for (const client of clients) {
if (client.resetFallbackFlag) {
client.resetFallbackFlag();
}
}
// Fetch downloads from all clients in parallel
const results = await Promise.allSettled(
clients.map(async (client) => {
try {
const downloads = await client.getActiveDownloads();
logToFile(`[DownloadClientRegistry] ${client.name}: ${downloads.length} downloads`);
return downloads;
} catch (error) {
logToFile(`[DownloadClientRegistry] Error fetching from ${client.name}: ${error.message}`);
return [];
}
})
);
// Flatten and return all downloads
const allDownloads = results
.filter(result => result.status === 'fulfilled')
.flatMap(result => result.value);
logToFile(`[DownloadClientRegistry] Total downloads from all clients: ${allDownloads.length}`);
return allDownloads;
}
/**
* Get downloads grouped by client type (for backward compatibility)
* @returns {Promise<Object>} Downloads grouped by client type
*/
async getDownloadsByClientType() {
const clients = this.getAllClients();
const result = {};
// Group by client type
for (const client of clients) {
const type = client.getClientType();
if (!result[type]) {
result[type] = [];
}
try {
const downloads = await client.getActiveDownloads();
result[type].push(...downloads);
} catch (error) {
logToFile(`[DownloadClientRegistry] Error fetching from ${client.name}: ${error.message}`);
}
}
return result;
}
/**
* Test connection to all clients
* @returns {Promise<Array<Object>>} Array of connection test results
*/
async testAllConnections() {
const clients = this.getAllClients();
const results = await Promise.allSettled(
clients.map(async (client) => {
try {
const success = await client.testConnection();
return {
instanceId: client.getInstanceId(),
instanceName: client.name,
clientType: client.getClientType(),
success,
error: null
};
} catch (error) {
return {
instanceId: client.getInstanceId(),
instanceName: client.name,
clientType: client.getClientType(),
success: false,
error: error.message
};
}
})
);
return results
.filter(result => result.status === 'fulfilled')
.map(result => result.value);
}
/**
* Get client status information from all clients
* @returns {Promise<Array<Object>>} Array of client status objects
*/
async getAllClientStatuses() {
const clients = this.getAllClients();
const results = await Promise.allSettled(
clients.map(async (client) => {
try {
const status = await client.getClientStatus();
return {
instanceId: client.getInstanceId(),
instanceName: client.name,
clientType: client.getClientType(),
status
};
} catch (error) {
logToFile(`[DownloadClientRegistry] Error getting status from ${client.name}: ${error.message}`);
return {
instanceId: client.getInstanceId(),
instanceName: client.name,
clientType: client.getClientType(),
status: null,
error: error.message
};
}
})
);
return results
.filter(result => result.status === 'fulfilled')
.map(result => result.value);
}
}
// Create singleton instance
const registry = new DownloadClientRegistry();
module.exports = {
DownloadClientRegistry,
registry,
// Convenience functions
initializeClients: () => registry.initialize(),
getAllClients: () => registry.getAllClients(),
getClient: (instanceId) => registry.getClient(instanceId),
getClientsByType: (type) => registry.getClientsByType(type),
getAllDownloads: () => registry.getAllDownloads(),
getDownloadsByClientType: () => registry.getDownloadsByClientType(),
testAllConnections: () => registry.testAllConnections(),
getAllClientStatuses: () => registry.getAllClientStatuses()
};
-153
View File
@@ -1,153 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const cache = require('./cache');
const { getSonarrInstances, getRadarrInstances } = require('./config');
const arrRetrieverRegistry = require('./arrRetrievers');
// Cache TTL for recent-history data: 5 minutes.
// History changes slowly compared to active downloads.
const HISTORY_CACHE_TTL = 5 * 60 * 1000;
// Sonarr event types that represent a successful import
const SONARR_IMPORTED_EVENTS = new Set(['downloadFolderImported', 'downloadImported']);
// Sonarr event types that represent a failed import
const SONARR_FAILED_EVENTS = new Set(['downloadFailed', 'importFailed']);
// Radarr equivalents
const RADARR_IMPORTED_EVENTS = new Set(['downloadFolderImported', 'downloadImported']);
const RADARR_FAILED_EVENTS = new Set(['downloadFailed', 'importFailed']);
/**
* Fetch recent history records from all Sonarr instances for the given date window.
* Results are cached under 'history:sonarr' for HISTORY_CACHE_TTL.
* @param {Date} since - Only include records on or after this date
* @returns {Promise<Array>} Flat array of Sonarr history records (with _instanceUrl and _instanceName)
*/
async function fetchSonarrHistory(since) {
const cacheKey = 'history:sonarr';
const cached = cache.get(cacheKey);
if (cached) return cached;
// Ensure retrievers are initialized
await arrRetrieverRegistry.initialize();
const instances = getSonarrInstances();
const sonarrRetrievers = arrRetrieverRegistry.getRetrieversByType('sonarr');
const results = await Promise.all(sonarrRetrievers.map(async (retriever) => {
const inst = instances.find(i => i.id === retriever.getInstanceId());
if (!inst) return [];
try {
const response = await retriever.getHistory({
pageSize: 100,
sortKey: 'date',
sortDir: 'descending',
includeSeries: true,
includeEpisode: true,
startDate: since.toISOString()
});
const records = (response && response.records) || [];
return records.map(r => {
if (r.series) r.series._instanceUrl = inst.url;
if (r.series) r.series._instanceName = inst.name || inst.id;
r._instanceUrl = inst.url;
r._instanceName = inst.name || inst.id;
return r;
});
} catch (err) {
console.error(`[HistoryFetcher] Sonarr ${inst.id} error:`, err.message);
return [];
}
}));
const flat = results.flat();
cache.set(cacheKey, flat, HISTORY_CACHE_TTL);
return flat;
}
/**
* Fetch recent history records from all Radarr instances for the given date window.
* Results are cached under 'history:radarr' for HISTORY_CACHE_TTL.
* @param {Date} since - Only include records on or after this date
* @returns {Promise<Array>} Flat array of Radarr history records (with _instanceUrl and _instanceName)
*/
async function fetchRadarrHistory(since) {
const cacheKey = 'history:radarr';
const cached = cache.get(cacheKey);
if (cached) return cached;
// Ensure retrievers are initialized
await arrRetrieverRegistry.initialize();
const instances = getRadarrInstances();
const radarrRetrievers = arrRetrieverRegistry.getRetrieversByType('radarr');
const results = await Promise.all(radarrRetrievers.map(async (retriever) => {
const inst = instances.find(i => i.id === retriever.getInstanceId());
if (!inst) return [];
try {
const response = await retriever.getHistory({
pageSize: 100,
sortKey: 'date',
sortDir: 'descending',
includeMovie: true,
startDate: since.toISOString()
});
const records = (response && response.records) || [];
return records.map(r => {
if (r.movie) r.movie._instanceUrl = inst.url;
if (r.movie) r.movie._instanceName = inst.name || inst.id;
r._instanceUrl = inst.url;
r._instanceName = inst.name || inst.id;
return r;
});
} catch (err) {
console.error(`[HistoryFetcher] Radarr ${inst.id} error:`, err.message);
return [];
}
}));
const flat = results.flat();
cache.set(cacheKey, flat, HISTORY_CACHE_TTL);
return flat;
}
/**
* Classify a Sonarr history record's event type.
* @param {string} eventType
* @returns {'imported'|'failed'|'other'}
*/
function classifySonarrEvent(eventType) {
if (SONARR_IMPORTED_EVENTS.has(eventType)) return 'imported';
if (SONARR_FAILED_EVENTS.has(eventType)) return 'failed';
return 'other';
}
/**
* Classify a Radarr history record's event type.
* @param {string} eventType
* @returns {'imported'|'failed'|'other'}
*/
function classifyRadarrEvent(eventType) {
if (RADARR_IMPORTED_EVENTS.has(eventType)) return 'imported';
if (RADARR_FAILED_EVENTS.has(eventType)) return 'failed';
return 'other';
}
/**
* Invalidate cached history so the next request fetches fresh data.
* Called externally if needed (e.g. after a forced refresh).
*/
function invalidateHistoryCache() {
cache.invalidate('history:sonarr');
cache.invalidate('history:radarr');
}
module.exports = {
fetchSonarrHistory,
fetchRadarrHistory,
classifySonarrEvent,
classifyRadarrEvent,
invalidateHistoryCache,
HISTORY_CACHE_TTL
};
-52
View File
@@ -1,52 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
//
// Docker secrets support: if an environment variable named FOO_FILE is set,
// read its contents from the file at that path and expose it as FOO.
// This follows the standard *_FILE convention used by official Docker images.
//
// Supported secrets:
// COOKIE_SECRET_FILE → COOKIE_SECRET
// EMBY_API_KEY_FILE → EMBY_API_KEY
// SABNZBD_API_KEY_FILE → SABNZBD_API_KEY (legacy single-instance)
// SONARR_API_KEY_FILE → SONARR_API_KEY (legacy single-instance)
// RADARR_API_KEY_FILE → RADARR_API_KEY (legacy single-instance)
// QBITTORRENT_PASSWORD_FILE → QBITTORRENT_PASSWORD (legacy single-instance)
//
// For multi-instance JSON arrays the secret values must be embedded in the
// JSON string itself; file-based loading is for the legacy single-key format.
const fs = require('fs');
const SECRET_MAPPINGS = [
'COOKIE_SECRET',
'EMBY_API_KEY',
'SABNZBD_API_KEY',
'SONARR_API_KEY',
'RADARR_API_KEY',
'QBITTORRENT_PASSWORD',
];
function loadSecrets() {
for (const key of SECRET_MAPPINGS) {
const fileEnv = `${key}_FILE`;
const filePath = process.env[fileEnv];
if (!filePath) continue;
if (process.env[key]) {
console.warn(`[Secrets] Both ${key} and ${fileEnv} are set — ${fileEnv} takes precedence`);
}
try {
const value = fs.readFileSync(filePath, 'utf8').trim();
if (!value) {
console.warn(`[Secrets] ${fileEnv} points to an empty file: ${filePath}`);
continue;
}
process.env[key] = value;
console.log(`[Secrets] Loaded ${key} from ${fileEnv}`);
} catch (err) {
console.error(`[Secrets] Failed to read ${fileEnv} (${filePath}): ${err.message}`);
process.exit(1);
}
}
}
module.exports = loadSecrets;
+1 -7
View File
@@ -1,13 +1,7 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const fs = require('fs');
const path = require('path');
// Use DATA_DIR so the non-root container user (UID 1000) can write logs.
// Falls back to ../../data/server.log (same directory index.js uses).
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../../data');
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
const logFile = fs.createWriteStream(path.join(DATA_DIR, 'server.log'), { flags: 'a' });
const logFile = fs.createWriteStream(path.join(__dirname, '../../server.log'), { flags: 'a' });
function logToFile(message) {
logFile.write(`[${new Date().toISOString()}] ${message}\n`);
-322
View File
@@ -1,322 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const axios = require('axios');
const cache = require('./cache');
const { initializeClients, getAllDownloads, getDownloadsByClientType } = require('./downloadClients');
const arrRetrieverRegistry = require('./arrRetrievers');
const {
getSonarrInstances,
getRadarrInstances
} = require('./config');
const rawPollInterval = (process.env.POLL_INTERVAL || '').toLowerCase();
const POLL_INTERVAL = (rawPollInterval === 'off' || rawPollInterval === 'false' || rawPollInterval === 'disabled')
? 0
: (parseInt(process.env.POLL_INTERVAL, 10) || 5000);
const POLLING_ENABLED = POLL_INTERVAL > 0;
// Webhook fallback timeout in minutes (default 10)
const WEBHOOK_FALLBACK_TIMEOUT_MINUTES = parseInt(process.env.WEBHOOK_FALLBACK_TIMEOUT, 10) || 10;
const WEBHOOK_FALLBACK_TIMEOUT_MS = WEBHOOK_FALLBACK_TIMEOUT_MINUTES * 60 * 1000;
// Webhook poll interval multiplier when webhooks are active (default 3x)
const WEBHOOK_POLL_INTERVAL_MULTIPLIER = parseInt(process.env.WEBHOOK_POLL_INTERVAL_MULTIPLIER, 10) || 3;
let polling = false;
let lastPollTimings = null;
// SSE subscribers: Set of () => void callbacks, each registered by an open /stream connection
const pollSubscribers = new Set();
function onPollComplete(cb) { pollSubscribers.add(cb); }
function offPollComplete(cb) { pollSubscribers.delete(cb); }
// Timed fetch helper: runs a fetch and records how long it took
async function timed(label, fn) {
const t0 = Date.now();
const result = await fn();
return { label, result, ms: Date.now() - t0 };
}
// Helper function to determine if instance polling should be skipped
function shouldSkipInstancePolling(instances, instanceType) {
if (!instances || instances.length === 0) {
return false;
}
const now = Date.now();
let allInstancesHaveRecentWebhooks = true;
let skippedCount = 0;
for (const instance of instances) {
const metrics = cache.getWebhookMetrics(instance.url);
// Skip polling if:
// 1. Webhook events have been received (eventsReceived > 0)
// 2. Last webhook was recent (within fallback timeout)
// 3. Webhook has been enabled (we have metrics)
const hasWebhookActivity = metrics && metrics.eventsReceived > 0;
const isRecent = metrics && metrics.lastWebhookTimestamp && (now - metrics.lastWebhookTimestamp) < WEBHOOK_FALLBACK_TIMEOUT_MS;
if (hasWebhookActivity && isRecent) {
skippedCount++;
cache.incrementPollsSkipped(instance.url);
} else {
allInstancesHaveRecentWebhooks = false;
}
}
if (allInstancesHaveRecentWebhooks && skippedCount > 0) {
console.log(`[Poller] Skipping ${instanceType} polling for ${skippedCount} instance(s) with active webhooks`);
return true;
}
return false;
}
async function pollAllServices() {
if (polling) {
console.log('[Poller] Previous poll still running, skipping');
return;
}
polling = true;
const start = Date.now();
try {
// Ensure download clients and *arr retrievers are initialized
await initializeClients();
await arrRetrieverRegistry.initialize();
const sonarrInstances = getSonarrInstances();
const radarrInstances = getRadarrInstances();
// Check webhook fallback: if no webhook events for WEBHOOK_FALLBACK_TIMEOUT, force full poll
const globalMetrics = cache.getGlobalWebhookMetrics();
const now = Date.now();
const lastWebhookTime = globalMetrics.lastGlobalWebhookTimestamp;
const fallbackTriggered = lastWebhookTime && (now - lastWebhookTime) > WEBHOOK_FALLBACK_TIMEOUT_MS;
if (fallbackTriggered) {
console.log(`[Poller] Webhook fallback triggered: no webhook events for ${WEBHOOK_FALLBACK_TIMEOUT_MINUTES} minutes, forcing full poll`);
}
// Determine which instances should be polled based on webhook activity
const shouldPollSonarr = fallbackTriggered || !shouldSkipInstancePolling(sonarrInstances, 'sonarr');
const shouldPollRadarr = fallbackTriggered || !shouldSkipInstancePolling(radarrInstances, 'radarr');
// All fetches in parallel, each individually timed
const results = await Promise.all([
timed('Download Clients', async () => {
const downloadsByType = await getDownloadsByClientType();
return downloadsByType;
}),
shouldPollSonarr ? timed('Sonarr Tags', async () => {
const tagsByType = await arrRetrieverRegistry.getTagsByType();
return tagsByType.sonarr || [];
}) : timed('Sonarr Tags', async () => []),
shouldPollSonarr ? timed('Sonarr Queue', async () => {
const queuesByType = await arrRetrieverRegistry.getQueuesByType();
return queuesByType.sonarr || [];
}) : timed('Sonarr Queue', async () => []),
shouldPollSonarr ? timed('Sonarr History', async () => {
const historyByType = await arrRetrieverRegistry.getHistoryByType({ pageSize: 10 });
return historyByType.sonarr || [];
}) : timed('Sonarr History', async () => []),
shouldPollRadarr ? timed('Radarr Queue', async () => {
const queuesByType = await arrRetrieverRegistry.getQueuesByType();
return queuesByType.radarr || [];
}) : timed('Radarr Queue', async () => []),
shouldPollRadarr ? timed('Radarr History', async () => {
const historyByType = await arrRetrieverRegistry.getHistoryByType({ pageSize: 10 });
return historyByType.radarr || [];
}) : timed('Radarr History', async () => []),
shouldPollRadarr ? timed('Radarr Tags', async () => {
const tagsByType = await arrRetrieverRegistry.getTagsByType();
return tagsByType.radarr || [];
}) : timed('Radarr Tags', async () => []),
]);
const [
{ result: downloadsByType },
{ result: sonarrTagsResults }, { result: sonarrQueues },
{ result: sonarrHistories },
{ result: radarrQueues }, { result: radarrHistories },
{ result: radarrTagsResults }
] = results;
// Store per-task timings
const totalMs = Date.now() - start;
lastPollTimings = {
totalMs,
timestamp: new Date().toISOString(),
tasks: results.map(r => ({ label: r.label, ms: r.ms }))
};
// When polling is active, TTL is 3x interval to avoid gaps between polls
// When polling is disabled (on-demand), use 30s so data refreshes on next request after expiry
const cacheTTL = POLLING_ENABLED ? POLL_INTERVAL * 3 : 30000;
// Download Clients (SABnzbd, qBittorrent, Transmission)
// Preserve backward compatibility with existing cache keys
const sabnzbdDownloads = downloadsByType.sabnzbd || [];
const qbittorrentDownloads = downloadsByType.qbittorrent || [];
// SABnzbd - separate queue and history based on source
const sabQueue = sabnzbdDownloads.filter(d => d.raw && d.raw.source === 'queue');
const sabHistory = sabnzbdDownloads.filter(d => d.raw && d.raw.source === 'history');
// Transform SABnzbd downloads to legacy format for cache
const sabQueueLegacy = {
slots: sabQueue.map(d => ({
nzo_id: d.id,
filename: d.title,
status: d.status,
progress: d.progress / 100,
mb: d.size / (1024 * 1024),
mbleft: (d.size - d.downloaded) / (1024 * 1024),
kbpersec: d.speed / 1024,
timeleft: d.eta ? `${Math.floor(d.eta / 60)}:${String(Math.floor(d.eta % 60)).padStart(2, '0')}` : 'unknown',
cat: d.category,
labels: d.tags.join(','),
added: d.addedOn ? Math.floor(new Date(d.addedOn).getTime() / 1000) : null,
raw: d.raw
}))
};
const sabHistoryLegacy = {
slots: sabHistory.map(d => ({
nzo_id: d.id,
filename: d.title,
status: d.status,
mb: d.size / (1024 * 1024),
cat: d.category,
labels: d.tags.join(','),
added: d.addedOn ? Math.floor(new Date(d.addedOn).getTime() / 1000) : null,
raw: d.raw
}))
};
// Extract status from first SABnzbd download if available
const firstSabDownload = sabQueue[0];
const sabStatus = firstSabDownload ? {
status: 'Active',
speed: firstSabDownload.speed,
kbpersec: firstSabDownload.speed / 1024
} : { status: 'Idle', speed: 0, kbpersec: 0 };
cache.set('poll:sab-queue', {
...sabQueueLegacy,
...sabStatus
}, cacheTTL);
cache.set('poll:sab-history', sabHistoryLegacy, cacheTTL);
// qBittorrent - transform to legacy format
const qbittorrentLegacy = qbittorrentDownloads.map(d => ({
...d.raw,
instanceId: d.instanceId,
instanceName: d.instanceName
}));
cache.set('poll:qbittorrent', qbittorrentLegacy, cacheTTL);
// Sonarr
if (shouldPollSonarr) {
cache.set('poll:sonarr-tags', sonarrTagsResults, cacheTTL);
// Tag queue/history records with _instanceUrl so embedded series/movie objects can build links
cache.set('poll:sonarr-queue', {
records: sonarrQueues.flatMap(q => {
const inst = sonarrInstances.find(i => i.id === q.instance);
const url = inst ? inst.url : null;
const key = inst ? inst.apiKey : null;
return (q.data.records || []).map(r => {
if (r.series) r.series._instanceUrl = url;
r._instanceUrl = url;
r._instanceKey = key;
return r;
});
})
}, cacheTTL);
cache.set('poll:sonarr-history', {
records: sonarrHistories.flatMap(h => h.data.records || [])
}, cacheTTL);
} else {
// Extend TTL of existing cached data when polling is skipped
const existingSonarrTags = cache.get('poll:sonarr-tags');
const existingSonarrQueue = cache.get('poll:sonarr-queue');
const existingSonarrHistory = cache.get('poll:sonarr-history');
if (existingSonarrTags) cache.set('poll:sonarr-tags', existingSonarrTags, cacheTTL);
if (existingSonarrQueue) cache.set('poll:sonarr-queue', existingSonarrQueue, cacheTTL);
if (existingSonarrHistory) cache.set('poll:sonarr-history', existingSonarrHistory, cacheTTL);
}
// Radarr
if (shouldPollRadarr) {
cache.set('poll:radarr-queue', {
records: radarrQueues.flatMap(q => {
const inst = radarrInstances.find(i => i.id === q.instance);
const url = inst ? inst.url : null;
const key = inst ? inst.apiKey : null;
return (q.data.records || []).map(r => {
if (r.movie) r.movie._instanceUrl = url;
r._instanceUrl = url;
r._instanceKey = key;
return r;
});
})
}, cacheTTL);
cache.set('poll:radarr-history', {
records: radarrHistories.flatMap(h => h.data.records || [])
}, cacheTTL);
cache.set('poll:radarr-tags', radarrTagsResults.flatMap(t => t.data || []), cacheTTL);
} else {
// Extend TTL of existing cached data when polling is skipped
const existingRadarrQueue = cache.get('poll:radarr-queue');
const existingRadarrHistory = cache.get('poll:radarr-history');
const existingRadarrTags = cache.get('poll:radarr-tags');
if (existingRadarrQueue) cache.set('poll:radarr-queue', existingRadarrQueue, cacheTTL);
if (existingRadarrHistory) cache.set('poll:radarr-history', existingRadarrHistory, cacheTTL);
if (existingRadarrTags) cache.set('poll:radarr-tags', existingRadarrTags, cacheTTL);
}
// qBittorrent (already set above in download clients section)
const elapsed = Date.now() - start;
console.log(`[Poller] Poll complete in ${elapsed}ms`);
// Notify all SSE stream connections so they push fresh data immediately
for (const cb of pollSubscribers) {
try { cb(); } catch { /* subscriber already disconnected */ }
}
} catch (err) {
console.error(`[Poller] Poll error:`, err.message);
} finally {
polling = false;
}
}
let intervalHandle = null;
function startPoller() {
if (!POLLING_ENABLED) {
console.log(`[Poller] Background polling disabled (POLL_INTERVAL=${process.env.POLL_INTERVAL || 'not set'}). Data will be fetched on-demand.`);
return;
}
console.log(`[Poller] Starting background poller (interval: ${POLL_INTERVAL}ms)`);
// Run immediately, then on interval
pollAllServices();
intervalHandle = setInterval(pollAllServices, POLL_INTERVAL);
}
function stopPoller() {
if (intervalHandle) {
clearInterval(intervalHandle);
intervalHandle = null;
console.log('[Poller] Stopped');
}
}
function getLastPollTimings() {
return lastPollTimings;
}
module.exports = { startPoller, stopPoller, pollAllServices, getLastPollTimings, onPollComplete, offPollComplete, POLL_INTERVAL, POLLING_ENABLED };
+116 -38
View File
@@ -1,47 +1,127 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
// Legacy compatibility layer - delegates to new DownloadClient system
const axios = require('axios');
const { logToFile } = require('./logger');
const { initializeClients, getClientsByType } = require('./downloadClients');
const { getQbittorrentInstances } = require('./config');
/**
* Legacy function for backward compatibility
* Returns all torrents from all qBittorrent instances
*/
async function getAllTorrents() {
try {
await initializeClients();
const clients = getClientsByType('qbittorrent');
class QBittorrentClient {
constructor(instance) {
this.id = instance.id;
this.name = instance.name;
this.url = instance.url;
this.username = instance.username;
this.password = instance.password;
this.authCookie = null;
}
async login() {
try {
logToFile(`[qBittorrent:${this.name}] Attempting login...`);
const response = await axios.post(`${this.url}/api/v2/auth/login`,
`username=${encodeURIComponent(this.username)}&password=${encodeURIComponent(this.password)}`,
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
maxRedirects: 0,
validateStatus: (status) => status >= 200 && status < 400
}
);
if (response.headers['set-cookie']) {
this.authCookie = response.headers['set-cookie'][0];
logToFile(`[qBittorrent:${this.name}] Login successful`);
return true;
}
logToFile(`[qBittorrent:${this.name}] Login failed - no cookie`);
return false;
} catch (error) {
logToFile(`[qBittorrent:${this.name}] Login error: ${error.message}`);
return false;
}
}
async makeRequest(endpoint, config = {}) {
const url = `${this.url}${endpoint}`;
if (clients.length === 0) {
logToFile('[qBittorrent] No instances configured');
if (!this.authCookie) {
const loggedIn = await this.login();
if (!loggedIn) {
throw new Error(`Failed to authenticate with ${this.name}`);
}
}
try {
const response = await axios.get(url, {
...config,
headers: {
...config.headers,
'Cookie': this.authCookie
}
});
return response;
} catch (error) {
// If unauthorized, try re-authenticating once
if (error.response && error.response.status === 403) {
logToFile(`[qBittorrent:${this.name}] Auth expired, re-authenticating...`);
this.authCookie = null;
const loggedIn = await this.login();
if (loggedIn) {
return axios.get(url, {
...config,
headers: {
...config.headers,
'Cookie': this.authCookie
}
});
}
}
throw error;
}
}
async getTorrents() {
try {
const response = await this.makeRequest('/api/v2/torrents/info');
logToFile(`[qBittorrent:${this.name}] Retrieved ${response.data.length} torrents`);
// Add instance info to each torrent
return response.data.map(torrent => ({
...torrent,
instanceId: this.id,
instanceName: this.name
}));
} catch (error) {
logToFile(`[qBittorrent:${this.name}] Error fetching torrents: ${error.message}`);
return [];
}
const results = await Promise.all(
clients.map(client => client.getActiveDownloads().catch(err => {
logToFile(`[qBittorrent] Error from ${client.name}: ${err.message}`);
return [];
}))
);
const allTorrents = results.flat();
// Convert back to legacy format for backward compatibility
const legacyTorrents = allTorrents.map(download => download.raw);
logToFile(`[qBittorrent] Total torrents from all instances: ${legacyTorrents.length}`);
return legacyTorrents;
} catch (error) {
logToFile(`[qBittorrent] Error in getAllTorrents: ${error.message}`);
return [];
}
}
/**
* Legacy function for backward compatibility
*/
function getClients() {
logToFile('[qBittorrent] getClients() called - delegating to new system');
return []; // Not used in new system
const instances = getQbittorrentInstances();
if (instances.length === 0) {
logToFile('[qBittorrent] No instances configured');
return [];
}
logToFile(`[qBittorrent] Created ${instances.length} client(s)`);
return instances.map(inst => new QBittorrentClient(inst));
}
async function getAllTorrents() {
const clients = getClients();
if (clients.length === 0) {
return [];
}
const results = await Promise.all(
clients.map(client => client.getTorrents().catch(err => {
logToFile(`[qBittorrent] Error from ${client.name}: ${err.message}`);
return [];
}))
);
const allTorrents = results.flat();
logToFile(`[qBittorrent] Total torrents from all instances: ${allTorrents.length}`);
return allTorrents;
}
function formatBytes(bytes) {
@@ -118,14 +198,12 @@ function mapTorrentToDownload(torrent) {
hash: torrent.hash,
category: torrent.category,
tags: torrent.tags,
savePath: torrent.content_path || torrent.save_path || null,
addedOn: torrent.added_on || null,
qbittorrent: true
};
}
module.exports = {
getAllTorrents,
getTorrents: getAllTorrents,
getClients,
mapTorrentToDownload,
formatBytes,
-27
View File
@@ -1,27 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
// Query-param secrets (SABnzbd apikey, generic token/password params)
const QUERY_SECRET_PATTERN = /([?&](?:apikey|token|password|api_key|key|secret)=)[^&\s#]*/gi;
// HTTP auth header values (X-Api-Key, X-MediaBrowser-Token, Authorization, X-Emby-Authorization)
// Redact everything after the colon to end-of-line (MediaBrowser headers span the full line)
const HEADER_PATTERN = /(?:x-api-key|x-mediabrowser-token|x-emby-authorization|authorization)\s*:[^\n]*/gi;
// Bearer tokens
const BEARER_PATTERN = /bearer\s+[A-Za-z0-9\-._~+/]+=*/gi;
// Basic auth credentials in URLs (http://user:pass@host)
const BASIC_AUTH_URL_PATTERN = /\/\/[^:@/\s]+:[^@/\s]+@/gi;
// Redact only the host:port authority portion of URLs, preserving path/query so
// other patterns (QUERY_SECRET_PATTERN etc.) can still act on them.
// Negative lookahead skips URLs already handled by BASIC_AUTH_URL_PATTERN.
const HOST_PATTERN = /(https?:\/\/)(?!\[REDACTED\]@)([^\s/?#]+)/gi;
function sanitizeError(err) {
let msg = (err && err.message) ? err.message : String(err);
msg = msg.replace(QUERY_SECRET_PATTERN, '$1[REDACTED]');
msg = msg.replace(HEADER_PATTERN, (m) => m.split(/[\s:]/)[0] + ':[REDACTED]');
msg = msg.replace(BEARER_PATTERN, 'bearer [REDACTED]');
msg = msg.replace(BASIC_AUTH_URL_PATTERN, '//[REDACTED]@'); // must run before HOST_PATTERN
msg = msg.replace(HOST_PATTERN, '$1[HOST]');
// Never leak stack traces to API responses
return msg;
}
module.exports = sanitizeError;
-98
View File
@@ -1,98 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Persistent token store backed by a JSON file.
*
* Pure JavaScript no native addons, no build tools required.
* Survives process restarts so users are not logged out on redeploy.
*
* Tokens are stored in DATA_DIR/tokens.json (default: ./data locally,
* /app/data in the container). Writes are atomic: data is written to a
* temp file then renamed so a crash mid-write never corrupts the store.
*
* Format: { "<userId>": { accessToken: "...", createdAt: <unix ms> } }
*
* Expired entries (older than TOKEN_TTL_DAYS) are pruned on startup
* and once per hour.
*/
const path = require('path');
const fs = require('fs');
const TOKEN_TTL_DAYS = 31; // slightly longer than max cookie lifetime (30d)
const TOKEN_TTL_MS = TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000;
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../../data');
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
const STORE_PATH = path.join(DATA_DIR, 'tokens.json');
const STORE_TMP = STORE_PATH + '.tmp';
// Load store from disk, return empty object on any error
function load() {
try {
return JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
} catch {
return {};
}
}
// Atomic write: write to .tmp then rename to avoid partial-write corruption
function save(data) {
try {
fs.writeFileSync(STORE_TMP, JSON.stringify(data), 'utf8');
fs.renameSync(STORE_TMP, STORE_PATH);
} catch (err) {
console.error('[TokenStore] Failed to persist token store:', err.message);
}
}
function prune(data) {
const cutoff = Date.now() - TOKEN_TTL_MS;
let pruned = 0;
for (const userId of Object.keys(data)) {
if (data[userId].createdAt < cutoff) {
delete data[userId];
pruned++;
}
}
if (pruned > 0) {
console.log(`[TokenStore] Pruned ${pruned} expired token(s)`);
}
return data;
}
// Prune on startup
let store = prune(load());
save(store);
// Prune once per hour (unref so it doesn't keep the process alive)
setInterval(() => {
store = prune(load());
save(store);
}, 60 * 60 * 1000).unref();
module.exports = {
storeToken(userId, accessToken) {
store[userId] = { accessToken, createdAt: Date.now() };
save(store);
},
getToken(userId) {
const entry = store[userId];
if (!entry) return null;
// Also honour TTL on read in case pruning hasn't run yet
if (Date.now() - entry.createdAt > TOKEN_TTL_MS) {
delete store[userId];
save(store);
return null;
}
return { accessToken: entry.accessToken };
},
clearToken(userId) {
if (store[userId]) {
delete store[userId];
save(store);
}
}
};
-71
View File
@@ -1,71 +0,0 @@
# Testing
## Stack
| Layer | Tool |
|---|---|
| Test runner | [Vitest](https://vitest.dev/) v4 |
| HTTP integration | [supertest](https://github.com/ladjs/supertest) |
| HTTP interception | [nock](https://github.com/nock/nock) (intercepts at Node http layer — works with CJS `require('axios')`) |
| Coverage | V8 (built-in, no Babel needed) |
## Running tests
```bash
# Run all tests once
npm test
# Watch mode (re-runs on file change)
npm run test:watch
# With coverage report
npm run test:coverage
# Interactive UI
npm run test:ui
```
Coverage output lands in `coverage/` (gitignored). Open `coverage/index.html` for the HTML report.
## Structure
```
tests/
├── setup.js # Global setup: isolated DATA_DIR, SKIP_RATE_LIMIT, console suppression
├── unit/
│ ├── sanitizeError.test.js # Secret redaction patterns (API keys, tokens, passwords)
│ ├── config.test.js # JSON array + legacy single-instance config parsing
│ ├── requireAuth.test.js # Auth middleware: valid/invalid/tampered cookies
│ ├── verifyCsrf.test.js # CSRF double-submit cookie pattern + timing-safe compare
│ ├── qbittorrent.test.js # Pure utils: formatBytes, formatEta, mapTorrentToDownload
│ └── tokenStore.test.js # JSON file token store: store/get/clear, TTL expiry
└── integration/
├── health.test.js # GET /health and /ready endpoints
├── auth.test.js # Full login/logout/me/csrf flows via supertest + nock
├── history.test.js # GET /api/history/recent: auth, filtering, deduplication
└── webhook.test.js # POST /api/webhook/sonarr+radarr: secret, validation,
# replay protection, metrics, security assertions
```
## Key design decisions
- **`server/app.js`** — Express factory extracted from `server/index.js`. Tests import `createApp()` without triggering the log-file setup, `process.exit()` calls, or background poller in the entry point.
- **nock over `vi.mock('axios')`** — Vitest's `vi.mock` only intercepts ESM `import` statements. Since `auth.js` uses CJS `require('axios')`, nock (which patches Node's `http`/`https` modules) is the correct tool for intercepting outbound requests.
- **`SKIP_RATE_LIMIT=1`** — All supertest requests originate from `127.0.0.1`, which would quickly exhaust per-IP rate-limit windows. Setting this env var raises the limits to `Number.MAX_SAFE_INTEGER` in both the API limiter and the login limiter.
- **Isolated `DATA_DIR`** — Each test worker gets a unique temp directory so `tokenStore.js` file I/O never conflicts with a running dev server.
- **`createApp({ skipRateLimits: true })`** — The app factory accepts an option to disable the general API rate limiter in addition to the env var for the login-specific limiter.
## Coverage targets
The tested files meet these per-file minimums (enforced in CI):
| File | Lines | Branches |
|---|---|---|
| `server/app.js` | 85% | 65% |
| `server/routes/auth.js` | 85% | 70% |
| `server/routes/webhook.js` | 80% | 70% |
| `server/middleware/requireAuth.js` | 75% | 80% |
| `server/utils/sanitizeError.js` | 60% | — |
| `server/utils/config.js` | 50% | 55% |
`dashboard.js` and `poller.js` are large files requiring complex external-service mocks (Sonarr, Radarr, qBittorrent, Emby) and are tracked as future test coverage work.
-300
View File
@@ -1,300 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Integration tests for authentication routes.
*
* Uses supertest against the createApp() factory (no real server).
* HTTP calls to Emby are intercepted at the Node http/https layer using nock,
* which works correctly with CJS require('axios') unlike vi.mock which only
* intercepts ESM imports.
*
* Covers:
* - Input validation on /login (empty fields, overlong values)
* - Successful login flow (cookies set, CSRF token returned)
* - Failed login (wrong credentials 401, no cookie set)
* - /me endpoint (authenticated vs unauthenticated)
* - /csrf token issuance
* - /logout (cookies cleared)
*/
import request from 'supertest';
import nock from 'nock';
import { createApp } from '../../server/app.js';
const EMBY_BASE = 'https://emby.test';
// Emby response fixtures
const EMBY_AUTH_BODY = {
AccessToken: 'test-emby-token-abc123',
User: { Id: 'user-id-001', Name: 'TestUser' }
};
const EMBY_USER_BODY = {
Id: 'user-id-001',
Name: 'TestUser',
Policy: { IsAdministrator: false }
};
const EMBY_ADMIN_BODY = {
Id: 'admin-id-001',
Name: 'AdminUser',
Policy: { IsAdministrator: true }
};
// Helper: intercept a successful Emby login + user-info sequence
function interceptSuccessfulLogin(userBody = EMBY_USER_BODY) {
nock(EMBY_BASE)
.post('/Users/authenticatebyname')
.reply(200, EMBY_AUTH_BODY);
nock(EMBY_BASE)
.get(/\/Users\//)
.reply(200, userBody);
}
afterEach(() => {
nock.cleanAll(); // remove any pending interceptors between tests
});
describe('POST /api/auth/login', () => {
// Each sub-describe gets a fresh app to avoid rate-limit state leaking
// between the 'input validation' calls (which all fail and count toward
// the 10-failure window) and the 'successful login' calls.
let app;
beforeEach(() => {
process.env.EMBY_URL = 'https://emby.test';
delete process.env.COOKIE_SECRET;
// skipRateLimits avoids 429s from the login limiter when all
// requests come from 127.0.0.1 in the test environment
app = createApp({ skipRateLimits: true });
vi.clearAllMocks();
});
afterEach(() => {
delete process.env.EMBY_URL;
delete process.env.COOKIE_SECRET;
});
describe('input validation', () => {
it('rejects empty username', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ username: '', password: 'pass' });
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
it('rejects missing password', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'alice', password: '' });
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
it('rejects username over 128 chars', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'a'.repeat(129), password: 'pass' });
expect(res.status).toBe(400);
});
it('rejects password over 256 chars', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'alice', password: 'p'.repeat(257) });
expect(res.status).toBe(400);
});
it('rejects non-string username', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ username: 123, password: 'pass' });
expect(res.status).toBe(400);
});
});
describe('successful login', () => {
it('returns success:true with user info', async () => {
interceptSuccessfulLogin();
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'TestUser', password: 'correct' });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.user.name).toBe('TestUser');
expect(res.body.user.isAdmin).toBe(false);
});
it('sets emby_user cookie', async () => {
interceptSuccessfulLogin();
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'TestUser', password: 'correct' });
const cookies = res.headers['set-cookie'] || [];
expect(cookies.some(c => c.startsWith('emby_user='))).toBe(true);
});
it('sets csrf_token cookie', async () => {
interceptSuccessfulLogin();
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'TestUser', password: 'correct' });
const cookies = res.headers['set-cookie'] || [];
expect(cookies.some(c => c.startsWith('csrf_token='))).toBe(true);
});
it('returns csrfToken in response body', async () => {
interceptSuccessfulLogin();
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'TestUser', password: 'correct' });
expect(typeof res.body.csrfToken).toBe('string');
expect(res.body.csrfToken.length).toBeGreaterThan(0);
});
it('session cookie has no maxAge when rememberMe is false', async () => {
interceptSuccessfulLogin();
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'TestUser', password: 'correct', rememberMe: false });
const cookies = res.headers['set-cookie'] || [];
const sessionCookie = cookies.find(c => c.startsWith('emby_user='));
// Session cookie must not persist across browser close
expect(sessionCookie).toBeDefined();
expect(sessionCookie).not.toContain('Max-Age');
});
it('sets 30-day maxAge when rememberMe is true', async () => {
interceptSuccessfulLogin();
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'TestUser', password: 'correct', rememberMe: true });
const cookies = res.headers['set-cookie'] || [];
const sessionCookie = cookies.find(c => c.startsWith('emby_user='));
expect(sessionCookie).toBeDefined();
expect(sessionCookie).toContain('Max-Age');
});
it('marks isAdmin correctly for admin user', async () => {
interceptSuccessfulLogin(EMBY_ADMIN_BODY);
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'AdminUser', password: 'correct' });
expect(res.status).toBe(200);
expect(res.body.user.isAdmin).toBe(true);
});
it('does not include AccessToken in response body', async () => {
interceptSuccessfulLogin();
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'TestUser', password: 'correct' });
// The Emby access token must never be sent to the client
expect(JSON.stringify(res.body)).not.toContain('test-emby-token-abc123');
});
});
describe('failed login', () => {
it('returns 401 when Emby rejects credentials', async () => {
nock(EMBY_BASE).post('/Users/authenticatebyname').reply(401, { error: 'Unauthorized' });
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'baduser', password: 'wrongpass' });
expect(res.status).toBe(401);
expect(res.body.success).toBe(false);
// Must not expose internal error details
expect(res.body.error).toBe('Invalid username or password');
});
it('does not set emby_user cookie on failure', async () => {
nock(EMBY_BASE).post('/Users/authenticatebyname').reply(401, {});
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'baduser', password: 'wrongpass' });
const cookies = res.headers['set-cookie'] || [];
expect(cookies.some(c => c.startsWith('emby_user='))).toBe(false);
});
});
});
describe('GET /api/auth/me', () => {
let app;
beforeEach(() => {
delete process.env.COOKIE_SECRET;
app = createApp({ skipRateLimits: true });
});
it('returns authenticated:false when no cookie', async () => {
const res = await request(app).get('/api/auth/me');
expect(res.status).toBe(200);
expect(res.body.authenticated).toBe(false);
});
it('returns authenticated:true with valid cookie', async () => {
const payload = JSON.stringify({ id: 'u1', name: 'Alice', isAdmin: false });
const res = await request(app)
.get('/api/auth/me')
.set('Cookie', `emby_user=${encodeURIComponent(payload)}`);
expect(res.body.authenticated).toBe(true);
expect(res.body.user.name).toBe('Alice');
});
});
describe('GET /api/auth/csrf', () => {
it('issues a csrf_token cookie and returns csrfToken in body', async () => {
const app = createApp({ skipRateLimits: true });
const res = await request(app).get('/api/auth/csrf');
expect(res.status).toBe(200);
expect(typeof res.body.csrfToken).toBe('string');
expect(res.body.csrfToken.length).toBe(64); // 32 bytes hex
const cookies = res.headers['set-cookie'] || [];
expect(cookies.some(c => c.startsWith('csrf_token='))).toBe(true);
});
});
describe('POST /api/auth/logout', () => {
let app;
beforeEach(() => {
process.env.EMBY_URL = 'https://emby.test';
delete process.env.COOKIE_SECRET;
app = createApp({ skipRateLimits: true });
vi.clearAllMocks();
});
afterEach(() => {
delete process.env.EMBY_URL;
});
// NOTE: /api/auth/* is mounted BEFORE the verifyCsrf middleware in app.js,
// so logout does not require a CSRF token by design. The session cookie's
// sameSite:strict attribute provides equivalent CSRF protection for logout.
it('succeeds without a CSRF token (sameSite:strict provides protection)', async () => {
nock(EMBY_BASE).post('/Sessions/Logout').reply(200, {});
const res = await request(app)
.post('/api/auth/logout');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
it('clears cookies and returns success when CSRF token is provided', async () => {
const csrfRes = await request(app).get('/api/auth/csrf');
const csrfToken = csrfRes.body.csrfToken;
const csrfCookie = csrfRes.headers['set-cookie'].find(c => c.startsWith('csrf_token='));
nock(EMBY_BASE).post('/Sessions/Logout').reply(200, {});
const res = await request(app)
.post('/api/auth/logout')
.set('Cookie', csrfCookie)
.set('X-CSRF-Token', csrfToken);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
// Cookies should be cleared (Set-Cookie header with empty value / Max-Age=0)
const cookies = res.headers['set-cookie'] || [];
expect(cookies.some(c => c.includes('emby_user=;') || c.includes('Max-Age=0'))).toBe(true);
});
});
-300
View File
@@ -1,300 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import {
initializeClients,
getAllDownloads,
getDownloadsByClientType,
testAllConnections,
registry
} from '../../server/utils/downloadClients.js';
import axios from 'axios';
import { vi } from 'vitest';
// Mock environment variables for testing
process.env.SABNZBD_INSTANCES = JSON.stringify([
{
id: 'test-sab',
name: 'Test SABnzbd',
url: 'http://localhost:8080',
apiKey: 'test-api-key'
}
]);
process.env.QBITTORRENT_INSTANCES = JSON.stringify([
{
id: 'test-qb',
name: 'Test qBittorrent',
url: 'http://localhost:8080',
username: 'admin',
password: 'adminadmin'
}
]);
process.env.TRANSMISSION_INSTANCES = JSON.stringify([
{
id: 'test-trans',
name: 'Test Transmission',
url: 'http://localhost:9091',
username: 'transmission',
password: 'transmission'
}
]);
process.env.RTORRENT_INSTANCES = JSON.stringify([
{
id: 'test-rtorrent',
name: 'Test rTorrent',
url: 'http://localhost:8080/RPC2',
username: 'rtorrent',
password: 'rtorrent'
}
]);
// Mock axios to prevent actual network calls
vi.mock('axios', () => {
const mockAxios = vi.fn();
mockAxios.post = vi.fn();
mockAxios.get = vi.fn();
return {
default: mockAxios,
post: vi.fn(),
get: vi.fn()
};
});
vi.mock('../../server/utils/logger', () => ({
logToFile: vi.fn()
}));
describe('Download Clients Integration Tests', () => {
beforeEach(() => {
registry.initialized = false;
registry.clients.clear();
vi.clearAllMocks();
});
describe('Client Initialization', () => {
it('should initialize all configured client types', async () => {
await initializeClients();
// The registry should have clients for all three types
const downloadsByType = await getDownloadsByClientType();
// Should have keys for each client type (even if empty due to mocked failures)
expect(typeof downloadsByType).toBe('object');
});
it('should handle missing environment variables gracefully', async () => {
// Temporarily clear environment variables
const originalSab = process.env.SABNZBD_INSTANCES;
const originalQb = process.env.QBITTORRENT_INSTANCES;
const originalTrans = process.env.TRANSMISSION_INSTANCES;
const originalRt = process.env.RTORRENT_INSTANCES;
delete process.env.SABNZBD_INSTANCES;
delete process.env.QBITTORRENT_INSTANCES;
delete process.env.TRANSMISSION_INSTANCES;
delete process.env.RTORRENT_INSTANCES;
await initializeClients();
const downloadsByType = await getDownloadsByClientType();
expect(Object.keys(downloadsByType)).toHaveLength(0);
// Restore environment variables
process.env.SABNZBD_INSTANCES = originalSab;
process.env.QBITTORRENT_INSTANCES = originalQb;
process.env.TRANSMISSION_INSTANCES = originalTrans;
process.env.RTORRENT_INSTANCES = originalRt;
});
});
describe('Download Aggregation', () => {
it('should aggregate downloads from multiple client types', async () => {
await initializeClients();
const downloadsByType = await getDownloadsByClientType();
const allDownloads = await getAllDownloads();
// Should return downloads grouped by type
expect(typeof downloadsByType).toBe('object');
// Should return flattened array of all downloads
expect(Array.isArray(allDownloads)).toBe(true);
// All downloads should have required normalized fields
allDownloads.forEach(download => {
expect(download).toHaveProperty('id');
expect(download).toHaveProperty('title');
expect(download).toHaveProperty('type');
expect(download).toHaveProperty('client');
expect(download).toHaveProperty('instanceId');
expect(download).toHaveProperty('instanceName');
expect(download).toHaveProperty('status');
expect(download).toHaveProperty('progress');
expect(download).toHaveProperty('size');
expect(download).toHaveProperty('downloaded');
expect(download).toHaveProperty('speed');
expect(download).toHaveProperty('raw');
});
});
it('should maintain type consistency across clients', async () => {
await initializeClients();
const downloadsByType = await getDownloadsByClientType();
// Check that each client type returns consistent data structure
Object.entries(downloadsByType).forEach(([clientType, downloads]) => {
if (downloads.length > 0) {
downloads.forEach(download => {
expect(download.client).toBe(clientType);
expect(download.type).toMatch(/^(usenet|torrent)$/);
expect(typeof download.progress).toBe('number');
expect(download.progress).toBeGreaterThanOrEqual(0);
expect(download.progress).toBeLessThanOrEqual(100);
expect(typeof download.size).toBe('number');
expect(typeof download.downloaded).toBe('number');
expect(typeof download.speed).toBe('number');
});
}
});
});
});
describe('Connection Testing', () => {
it('should test connections for all configured clients', async () => {
await initializeClients();
const results = await testAllConnections();
expect(Array.isArray(results)).toBe(true);
results.forEach(result => {
expect(result).toHaveProperty('instanceId');
expect(result).toHaveProperty('instanceName');
expect(result).toHaveProperty('clientType');
expect(result).toHaveProperty('success');
expect(typeof result.success).toBe('boolean');
});
});
it('should handle connection failures gracefully', async () => {
// This test verifies that connection failures don't crash the system
await initializeClients();
const results = await testAllConnections();
// Should still return results even if connections fail
expect(results.length).toBeGreaterThan(0);
});
});
describe('Error Handling and Resilience', () => {
it('should handle individual client failures without affecting others', async () => {
await initializeClients();
// Even if some clients fail, others should still work
const downloadsByType = await getDownloadsByClientType();
const allDownloads = await getAllDownloads();
expect(typeof downloadsByType).toBe('object');
expect(Array.isArray(allDownloads)).toBe(true);
});
it('should handle malformed configuration gracefully', async () => {
// Test with malformed JSON
const originalSab = process.env.SABNZBD_INSTANCES;
process.env.SABNZBD_INSTANCES = 'invalid-json{';
// Should not throw an error
await expect(initializeClients()).resolves.not.toThrow();
// Restore
process.env.SABNZBD_INSTANCES = originalSab;
});
it('should handle network timeouts and errors', async () => {
await initializeClients();
// Mock network failures by setting up axios to reject
axios.get.mockRejectedValue(new Error('Network timeout'));
axios.post.mockRejectedValue(new Error('Network timeout'));
// Should handle errors gracefully and return empty results
const downloads = await getAllDownloads();
expect(Array.isArray(downloads)).toBe(true);
});
});
describe('Backward Compatibility', () => {
it('should maintain compatibility with existing cache structure', async () => {
await initializeClients();
const downloadsByType = await getDownloadsByClientType();
// SABnzbd downloads should have raw data for legacy compatibility
if (downloadsByType.sabnzbd && downloadsByType.sabnzbd.length > 0) {
downloadsByType.sabnzbd.forEach(download => {
expect(download.raw).toBeTruthy();
expect(download.raw.source).toMatch(/^(queue|history)$/);
});
}
// qBittorrent downloads should have raw data for legacy compatibility
if (downloadsByType.qbittorrent && downloadsByType.qbittorrent.length > 0) {
downloadsByType.qbittorrent.forEach(download => {
expect(download.raw).toBeTruthy();
expect(download.raw.hash).toBeTruthy(); // qBittorrent specific field
});
}
});
});
describe('Performance and Scalability', () => {
it('should handle multiple instances of the same client type', async () => {
// Configure multiple instances
process.env.QBITTORRENT_INSTANCES = JSON.stringify([
{
id: 'test-qb-1',
name: 'Test qBittorrent 1',
url: 'http://localhost:8080',
username: 'admin',
password: 'adminadmin'
},
{
id: 'test-qb-2',
name: 'Test qBittorrent 2',
url: 'http://localhost:8081',
username: 'admin',
password: 'adminadmin'
}
]);
await initializeClients();
const downloadsByType = await getDownloadsByClientType();
// Should aggregate downloads from both instances
expect(Array.isArray(downloadsByType.qbittorrent)).toBe(true);
// Each download should have correct instance information
downloadsByType.qbittorrent.forEach(download => {
expect(download.instanceId).toMatch(/^(test-qb-1|test-qb-2)$/);
expect(download.instanceName).toMatch(/^(Test qBittorrent 1|Test qBittorrent 2)$/);
});
});
it('should execute client requests in parallel', async () => {
const startTime = Date.now();
await initializeClients();
await getAllDownloads();
const endTime = Date.now();
const duration = endTime - startTime;
// This is a rough check - in a real scenario with actual network calls,
// parallel execution should be significantly faster than sequential
expect(duration).toBeGreaterThan(0);
});
});
});
-56
View File
@@ -1,56 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Integration tests for health and readiness endpoints.
*
* /health and /ready are used by Docker HEALTHCHECK and must:
* - Require no authentication
* - Not be rate-limited
* - Return the correct status codes
*/
import request from 'supertest';
import { createApp } from '../../server/app.js';
describe('GET /health', () => {
let app;
beforeEach(() => {
app = createApp();
});
it('returns 200 with status ok', async () => {
const res = await request(app).get('/health');
expect(res.status).toBe(200);
expect(res.body.status).toBe('ok');
});
it('includes uptime as a number', async () => {
const res = await request(app).get('/health');
expect(typeof res.body.uptime).toBe('number');
expect(res.body.uptime).toBeGreaterThanOrEqual(0);
});
});
describe('GET /ready', () => {
let app;
afterEach(() => {
delete process.env.EMBY_URL;
});
it('returns 200 when EMBY_URL is configured', async () => {
process.env.EMBY_URL = 'https://emby.local';
app = createApp();
const res = await request(app).get('/ready');
expect(res.status).toBe(200);
expect(res.body.status).toBe('ready');
});
it('returns 503 when EMBY_URL is not configured', async () => {
delete process.env.EMBY_URL;
app = createApp();
const res = await request(app).get('/ready');
expect(res.status).toBe(503);
expect(res.body.status).toBe('not ready');
});
});
-401
View File
@@ -1,401 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Integration tests for GET /api/history/recent
*
* Uses supertest against createApp() with vi.mock to stub historyFetcher
* (avoids nock/ESM-CJS interop issues with axios) and nock for Emby auth.
* Covers:
* - 401 when unauthenticated
* - Empty history response when arr returns no records
* - Filters out records whose eventType is not imported/failed
* - Returns imported and failed records for tagged series/movies
* - ?days= param is respected (default 7, capped at 90)
* - failureMessage included for admins on failed records
*/
import request from 'supertest';
import nock from 'nock';
import { beforeEach, afterEach } from 'vitest';
import { createRequire } from 'module';
import { createApp } from '../../server/app.js';
// Use createRequire to get the same CJS singleton cache instance that
// server/utils/historyFetcher.js and server/routes/history.js use via
// require('./cache'). A plain ESM `import cache from '...'` resolves
// to a different module identity under vitest's ESM runtime.
const require = createRequire(import.meta.url);
const cache = require('../../server/utils/cache.js');
const EMBY_BASE = 'https://emby.test';
process.env.EMBY_URL = EMBY_BASE;
process.env.SONARR_INSTANCES = JSON.stringify([
{ id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test', apiKey: 'sk' }
]);
process.env.RADARR_INSTANCES = JSON.stringify([
{ id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test', apiKey: 'rk' }
]);
// --- Fixtures ---
const EMBY_AUTH = { AccessToken: 'tok', User: { Id: 'uid1', Name: 'alice' } };
const EMBY_USER = { Id: 'uid1', Name: 'alice', Policy: { IsAdministrator: false } };
const EMBY_ADMIN = { Id: 'uid2', Name: 'admin', Policy: { IsAdministrator: true } };
const EMBY_AUTH_ADMIN = { AccessToken: 'tok2', User: { Id: 'uid2', Name: 'admin' } };
// Sonarr tag: id 1 → 'alice'
const SONARR_TAGS = [{ id: 1, label: 'alice' }];
const SONARR_RECORD_IMPORTED = {
id: 100,
eventType: 'downloadFolderImported',
sourceTitle: 'Show.S01E01.720p',
date: new Date().toISOString(),
quality: { quality: { name: '720p' } },
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
seriesId: 10
};
const SONARR_RECORD_FAILED = {
id: 101,
eventType: 'downloadFailed',
sourceTitle: 'Show.S01E02.720p',
date: new Date().toISOString(),
quality: { quality: { name: '720p' } },
data: { message: 'Not enough disk space' },
series: { id: 11, title: 'Admin Show', titleSlug: 'admin-show', tags: [2], images: [] },
};
// Tag id 2 → 'admin' (used in the failed-import admin test)
const SONARR_TAGS_WITH_ADMIN = [{ id: 1, label: 'alice' }, { id: 2, label: 'admin' }];
const SONARR_RECORD_FAILED_ALICE = { // failed record tagged alice, for event-type filtering test
id: 103,
eventType: 'downloadFailed',
sourceTitle: 'Show.S01E02.720p',
date: new Date().toISOString(),
quality: { quality: { name: '720p' } },
data: { message: 'Disk full' },
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
seriesId: 10
};
const SONARR_RECORD_GRABBED = {
id: 102,
eventType: 'grabbed',
sourceTitle: 'Show.S01E03.720p',
date: new Date().toISOString(),
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
seriesId: 10
};
const RADARR_RECORD_IMPORTED = {
id: 200,
eventType: 'downloadFolderImported',
sourceTitle: 'My.Movie.2024.1080p',
date: new Date().toISOString(),
quality: { quality: { name: '1080p' } },
movie: { id: 20, title: 'My Movie', titleSlug: 'my-movie-2024', tags: [1], images: [] },
movieId: 20
};
// Deduplication fixtures — same episodeId 55, episode 1 failed then imported
const SONARR_RECORD_FAILED_EP55 = {
id: 110,
eventType: 'downloadFailed',
sourceTitle: 'Show.S02E01.720p',
date: new Date(Date.now() - 3600000).toISOString(), // 1 hour ago
quality: { quality: { name: '720p' } },
data: { message: 'Download failed' },
episodeId: 55,
episode: { seasonNumber: 2, episodeNumber: 1, title: 'Pilot', hasFile: false },
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
seriesId: 10
};
const SONARR_RECORD_IMPORTED_EP55 = {
id: 111,
eventType: 'downloadFolderImported',
sourceTitle: 'Show.S02E01.720p',
date: new Date().toISOString(), // now (more recent)
quality: { quality: { name: '720p' } },
episodeId: 55,
episode: { seasonNumber: 2, episodeNumber: 1, title: 'Pilot', hasFile: true },
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
seriesId: 10
};
// Failed, still failing (hasFile=false) — most recent is a failure with no file
const SONARR_RECORD_FAILED_EP56 = {
id: 112,
eventType: 'downloadFailed',
sourceTitle: 'Show.S02E02.720p',
date: new Date().toISOString(),
quality: { quality: { name: '720p' } },
data: { message: 'No seeders' },
episodeId: 56,
episode: { seasonNumber: 2, episodeNumber: 2, title: 'Episode 2', hasFile: false },
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
seriesId: 10
};
// Failed but hasFile=true — episode is available, failure is an upgrade attempt
const SONARR_RECORD_FAILED_EP57_HAS_FILE = {
id: 113,
eventType: 'downloadFailed',
sourceTitle: 'Show.S02E03.720p',
date: new Date().toISOString(),
quality: { quality: { name: '720p' } },
data: { message: 'Upgrade failed' },
episodeId: 57,
episode: { seasonNumber: 2, episodeNumber: 3, title: 'Episode 3', hasFile: true },
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
seriesId: 10
};
// --- Helpers ---
function interceptLogin(userBody = EMBY_USER, authBody = EMBY_AUTH) {
nock(EMBY_BASE).post('/Users/authenticatebyname').reply(200, authBody);
nock(EMBY_BASE).get(/\/Users\//).reply(200, userBody);
}
// Pre-seed the history cache keys that fetchSonarrHistory/fetchRadarrHistory check
// first, so they return without making any HTTP calls.
const CACHE_TTL = 60 * 60 * 1000; // 1 hour — won't expire during a test run
function setHistory(sonarrRecords = [], radarrRecords = []) {
cache.set('history:sonarr', sonarrRecords.map(r => ({
...r,
_instanceName: 'Main Sonarr',
series: r.series ? { ...r.series, _instanceUrl: 'https://sonarr.test', _instanceName: 'Main Sonarr' } : undefined
})), CACHE_TTL);
cache.set('history:radarr', radarrRecords.map(r => ({
...r,
_instanceName: 'Main Radarr',
movie: r.movie ? { ...r.movie, _instanceUrl: 'https://radarr.test', _instanceName: 'Main Radarr' } : undefined
})), CACHE_TTL);
}
async function loginAs(app, userBody = EMBY_USER, authBody = EMBY_AUTH) {
interceptLogin(userBody, authBody);
const res = await request(app)
.post('/api/auth/login')
.send({ username: userBody.Name, password: 'pw' });
const cookies = res.headers['set-cookie'];
const csrf = res.body.csrfToken;
return { cookies, csrf };
}
beforeEach(() => {
// Clear history caches so each test controls its own data
cache.invalidate('history:sonarr');
cache.invalidate('history:radarr');
// Default: empty history
setHistory([], []);
// Seed poll tag caches so the route can resolve tags
cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS }], 60000);
cache.set('poll:radarr-tags', [], 60000);
});
afterEach(() => {
nock.cleanAll();
cache.invalidate('history:sonarr');
cache.invalidate('history:radarr');
});
describe('GET /api/history/recent', () => {
describe('authentication', () => {
it('returns 401 when not logged in', async () => {
const app = createApp({ skipRateLimits: true });
const res = await request(app).get('/api/history/recent');
expect(res.status).toBe(401);
});
});
describe('empty history', () => {
it('returns empty array when arr returns no records', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
const res = await request(app)
.get('/api/history/recent')
.set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.history).toEqual([]);
expect(res.body.days).toBe(7);
});
});
describe('event type filtering', () => {
it('includes imported and failed records, excludes grabbed', async () => {
const app = createApp({ skipRateLimits: true });
setHistory(
[SONARR_RECORD_IMPORTED, SONARR_RECORD_FAILED_ALICE, SONARR_RECORD_GRABBED],
[]
);
const { cookies } = await loginAs(app);
const res = await request(app)
.get('/api/history/recent')
.set('Cookie', cookies);
expect(res.status).toBe(200);
const outcomes = res.body.history.map(h => h.outcome);
expect(outcomes).toContain('imported');
expect(outcomes).toContain('failed');
expect(res.body.history).toHaveLength(2);
});
});
describe('tag filtering', () => {
it('only returns records tagged for the current user', async () => {
const app = createApp({ skipRateLimits: true });
setHistory([SONARR_RECORD_IMPORTED], []);
const { cookies } = await loginAs(app);
const res = await request(app)
.get('/api/history/recent')
.set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.history).toHaveLength(1);
expect(res.body.history[0].seriesName).toBe('My Show');
expect(res.body.history[0].outcome).toBe('imported');
expect(res.body.history[0].quality).toBe('720p');
});
it('excludes records tagged for a different user', async () => {
const app = createApp({ skipRateLimits: true });
const bobAuth = { AccessToken: 'tok3', User: { Id: 'uid3', Name: 'bob' } };
const bobUser = { Id: 'uid3', Name: 'bob', Policy: { IsAdministrator: false } };
setHistory([SONARR_RECORD_IMPORTED], []);
const { cookies } = await loginAs(app, bobUser, bobAuth);
const res = await request(app)
.get('/api/history/recent')
.set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.history).toHaveLength(0);
});
});
describe('radarr records', () => {
it('returns movie history items', async () => {
const app = createApp({ skipRateLimits: true });
cache.set('poll:radarr-tags', [{ id: 1, label: 'alice' }], 60000);
setHistory([], [RADARR_RECORD_IMPORTED]);
const { cookies } = await loginAs(app);
const res = await request(app)
.get('/api/history/recent')
.set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.history).toHaveLength(1);
expect(res.body.history[0].type).toBe('movie');
expect(res.body.history[0].movieName).toBe('My Movie');
});
});
describe('?days parameter', () => {
it('uses custom days value', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
const res = await request(app)
.get('/api/history/recent?days=14')
.set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.days).toBe(14);
});
it('caps days at 90', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
const res = await request(app)
.get('/api/history/recent?days=999')
.set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.days).toBe(7); // falls back to default when > 90
});
});
describe('failed import details', () => {
it('includes failureMessage for admin on failed records', async () => {
const app = createApp({ skipRateLimits: true });
cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS_WITH_ADMIN }], 60000);
setHistory([SONARR_RECORD_FAILED], []);
const { cookies } = await loginAs(app, EMBY_ADMIN, EMBY_AUTH_ADMIN);
const res = await request(app)
.get('/api/history/recent')
.set('Cookie', cookies);
expect(res.status).toBe(200);
const failed = res.body.history.find(h => h.outcome === 'failed');
expect(failed).toBeDefined();
expect(failed.failureMessage).toBe('Not enough disk space');
});
});
describe('deduplication', () => {
it('suppresses a failed record when the same episode was subsequently imported', async () => {
const app = createApp({ skipRateLimits: true });
// API returns newest-first: imported (now) before failed (1hr ago)
setHistory([SONARR_RECORD_IMPORTED_EP55, SONARR_RECORD_FAILED_EP55], []);
const { cookies } = await loginAs(app);
const res = await request(app)
.get('/api/history/recent')
.set('Cookie', cookies);
expect(res.status).toBe(200);
const ep55Items = res.body.history.filter(h => h.seriesName === 'My Show' && h.title.includes('S02E01'));
expect(ep55Items).toHaveLength(1);
expect(ep55Items[0].outcome).toBe('imported');
});
it('shows a failed record as-is when there is no successful import and hasFile is false', async () => {
const app = createApp({ skipRateLimits: true });
setHistory([SONARR_RECORD_FAILED_EP56], []);
const { cookies } = await loginAs(app);
const res = await request(app)
.get('/api/history/recent')
.set('Cookie', cookies);
expect(res.status).toBe(200);
const item = res.body.history.find(h => h.title && h.title.includes('S02E02'));
expect(item).toBeDefined();
expect(item.outcome).toBe('failed');
expect(item.availableForUpgrade).toBeFalsy();
});
it('flags a failed record as availableForUpgrade when the episode hasFile is true', async () => {
const app = createApp({ skipRateLimits: true });
setHistory([SONARR_RECORD_FAILED_EP57_HAS_FILE], []);
const { cookies } = await loginAs(app);
const res = await request(app)
.get('/api/history/recent')
.set('Cookie', cookies);
expect(res.status).toBe(200);
const item = res.body.history.find(h => h.title && h.title.includes('S02E03'));
expect(item).toBeDefined();
expect(item.outcome).toBe('failed');
expect(item.availableForUpgrade).toBe(true);
});
it('does not expose _contentId in the response', async () => {
const app = createApp({ skipRateLimits: true });
setHistory([SONARR_RECORD_IMPORTED_EP55], []);
const { cookies } = await loginAs(app);
const res = await request(app)
.get('/api/history/recent')
.set('Cookie', cookies);
expect(res.status).toBe(200);
for (const item of res.body.history) {
expect(item).not.toHaveProperty('_contentId');
}
});
});
describe('response shape', () => {
it('returns correct top-level fields', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
const res = await request(app)
.get('/api/history/recent')
.set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('user');
expect(res.body).toHaveProperty('isAdmin');
expect(res.body).toHaveProperty('days');
expect(res.body).toHaveProperty('history');
expect(Array.isArray(res.body.history)).toBe(true);
});
});
});
-395
View File
@@ -1,395 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Integration tests for webhook endpoints:
* POST /api/webhook/sonarr
* POST /api/webhook/radarr
*
* Uses supertest against createApp() (no real server).
* processWebhookEvent() makes outbound *arr API calls those are blocked by
* nock so tests remain hermetic (fire-and-forget, not awaited by the handler).
*
* Covers:
* - 401 when X-Sofarr-Webhook-Secret is missing or wrong
* - 400 when payload is invalid (missing/unknown eventType, non-object body)
* - 200 + { received: true } for valid events
* - Replay protection: second identical event returns { duplicate: true }
* - Test event (eventType=Test) is accepted and short-circuits the cache refresh
* - cache.updateWebhookMetrics is called when a known instance name is provided
* - cache.getGlobalWebhookMetrics reflects the recorded event
*/
import request from 'supertest';
import nock from 'nock';
import { beforeEach, afterEach } from 'vitest';
import { createRequire } from 'module';
import { createApp } from '../../server/app.js';
const require = createRequire(import.meta.url);
const cache = require('../../server/utils/cache.js');
const VALID_SECRET = 'test-webhook-secret-abc';
// Minimal valid Sonarr Grab payload
const SONARR_GRAB = {
eventType: 'Grab',
instanceName: 'Main Sonarr',
date: '2026-05-19T10:00:00.000Z',
series: { id: 1, title: 'Test Show' },
episodes: [{ id: 10, episodeNumber: 1, seasonNumber: 1 }]
};
// Minimal valid Radarr Grab payload
const RADARR_GRAB = {
eventType: 'Grab',
instanceName: 'Main Radarr',
date: '2026-05-19T10:00:01.000Z',
movie: { id: 1, title: 'Test Movie' }
};
// Minimal Test event (sent by *arr "Test" button in notifications settings)
const SONARR_TEST = {
eventType: 'Test',
instanceName: 'Main Sonarr',
date: '2026-05-19T10:00:02.000Z'
};
function makeApp() {
process.env.SOFARR_WEBHOOK_SECRET = VALID_SECRET;
process.env.SONARR_INSTANCES = JSON.stringify([
{ id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test', apiKey: 'sk' }
]);
process.env.RADARR_INSTANCES = JSON.stringify([
{ id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test', apiKey: 'rk' }
]);
return createApp({ skipRateLimits: true });
}
function postSonarr(app, payload, secret = VALID_SECRET) {
const req = request(app).post('/api/webhook/sonarr').send(payload);
if (secret !== null) req.set('X-Sofarr-Webhook-Secret', secret);
return req;
}
function postRadarr(app, payload, secret = VALID_SECRET) {
const req = request(app).post('/api/webhook/radarr').send(payload);
if (secret !== null) req.set('X-Sofarr-Webhook-Secret', secret);
return req;
}
beforeEach(() => {
// Block outbound *arr calls made by processWebhookEvent (fire-and-forget)
nock('https://sonarr.test').persist().get(/.*/).reply(200, { records: [] });
nock('https://radarr.test').persist().get(/.*/).reply(200, { records: [] });
});
afterEach(() => {
nock.cleanAll();
delete process.env.SOFARR_WEBHOOK_SECRET;
});
// ---------------------------------------------------------------------------
// Secret validation
// ---------------------------------------------------------------------------
describe('POST /api/webhook/sonarr — secret validation', () => {
it('returns 401 when X-Sofarr-Webhook-Secret header is missing', async () => {
const app = makeApp();
const res = await postSonarr(app, SONARR_GRAB, null);
expect(res.status).toBe(401);
expect(res.body.error).toBe('Unauthorized');
});
it('returns 401 when X-Sofarr-Webhook-Secret header is wrong', async () => {
const app = makeApp();
const res = await postSonarr(app, SONARR_GRAB, 'wrong-secret');
expect(res.status).toBe(401);
expect(res.body.error).toBe('Unauthorized');
});
it('returns 401 when SOFARR_WEBHOOK_SECRET is not configured', async () => {
delete process.env.SOFARR_WEBHOOK_SECRET;
const app = createApp({ skipRateLimits: true });
const res = await postSonarr(app, SONARR_GRAB, 'anything');
expect(res.status).toBe(401);
});
});
describe('POST /api/webhook/radarr — secret validation', () => {
it('returns 401 when X-Sofarr-Webhook-Secret header is missing', async () => {
const app = makeApp();
const res = await postRadarr(app, RADARR_GRAB, null);
expect(res.status).toBe(401);
expect(res.body.error).toBe('Unauthorized');
});
it('returns 401 when X-Sofarr-Webhook-Secret header is wrong', async () => {
const app = makeApp();
const res = await postRadarr(app, RADARR_GRAB, 'bad-secret');
expect(res.status).toBe(401);
});
});
// ---------------------------------------------------------------------------
// Input validation
// ---------------------------------------------------------------------------
describe('POST /api/webhook/sonarr — input validation', () => {
it('returns 400 when body is not a JSON object (array)', async () => {
const app = makeApp();
const res = await request(app)
.post('/api/webhook/sonarr')
.set('X-Sofarr-Webhook-Secret', VALID_SECRET)
.send([{ eventType: 'Grab' }]);
expect(res.status).toBe(400);
});
it('returns 400 when eventType is missing', async () => {
const app = makeApp();
const res = await postSonarr(app, { instanceName: 'Main Sonarr' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/eventType/);
});
it('returns 400 when eventType is an unknown value', async () => {
const app = makeApp();
const res = await postSonarr(app, { eventType: 'HackerThing', date: '2026-01-01T00:00:00Z' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/Unknown eventType/);
});
it('returns 400 when eventType is not a string', async () => {
const app = makeApp();
const res = await postSonarr(app, { eventType: 42 });
expect(res.status).toBe(400);
});
it('returns 400 when eventType exceeds 64 characters', async () => {
const app = makeApp();
const res = await postSonarr(app, { eventType: 'G'.repeat(65) });
expect(res.status).toBe(400);
});
it('returns 400 when instanceName is not a string', async () => {
const app = makeApp();
const res = await postSonarr(app, { eventType: 'Grab', instanceName: 99 });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/instanceName/);
});
});
describe('POST /api/webhook/radarr — input validation', () => {
it('returns 400 when eventType is missing', async () => {
const app = makeApp();
const res = await postRadarr(app, { instanceName: 'Main Radarr' });
expect(res.status).toBe(400);
});
it('returns 400 when eventType is unknown', async () => {
const app = makeApp();
const res = await postRadarr(app, { eventType: 'Injected', date: '2026-01-01T00:00:00Z' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/Unknown eventType/);
});
});
// ---------------------------------------------------------------------------
// Happy path — valid events
// ---------------------------------------------------------------------------
describe('POST /api/webhook/sonarr — valid events', () => {
it('returns 200 { received: true } for a valid Grab event', async () => {
const app = makeApp();
const payload = { ...SONARR_GRAB, date: '2026-05-19T11:00:00.000Z' };
const res = await postSonarr(app, payload);
expect(res.status).toBe(200);
expect(res.body.received).toBe(true);
expect(res.body.duplicate).toBeUndefined();
});
it('returns 200 { received: true } for a Test event', async () => {
const app = makeApp();
const payload = { ...SONARR_TEST, date: '2026-05-19T11:01:00.000Z' };
const res = await postSonarr(app, payload);
expect(res.status).toBe(200);
expect(res.body.received).toBe(true);
});
it('accepts DownloadFolderImported event', async () => {
const app = makeApp();
const res = await postSonarr(app, {
eventType: 'DownloadFolderImported',
instanceName: 'Main Sonarr',
date: '2026-05-19T11:02:00.000Z'
});
expect(res.status).toBe(200);
expect(res.body.received).toBe(true);
});
it('accepts event without instanceName field', async () => {
const app = makeApp();
const res = await postSonarr(app, {
eventType: 'Grab',
date: '2026-05-19T11:03:00.000Z'
});
expect(res.status).toBe(200);
expect(res.body.received).toBe(true);
});
});
describe('POST /api/webhook/radarr — valid events', () => {
it('returns 200 { received: true } for a valid Grab event', async () => {
const app = makeApp();
const payload = { ...RADARR_GRAB, date: '2026-05-19T12:00:00.000Z' };
const res = await postRadarr(app, payload);
expect(res.status).toBe(200);
expect(res.body.received).toBe(true);
});
it('accepts Download event', async () => {
const app = makeApp();
const res = await postRadarr(app, {
eventType: 'Download',
instanceName: 'Main Radarr',
date: '2026-05-19T12:01:00.000Z'
});
expect(res.status).toBe(200);
});
});
// ---------------------------------------------------------------------------
// Replay protection
// ---------------------------------------------------------------------------
describe('Replay protection', () => {
it('sonarr: second identical event (same date) returns duplicate:true', async () => {
const app = makeApp();
const payload = {
eventType: 'Grab',
instanceName: 'Main Sonarr',
date: '2026-05-19T13:00:00.000Z'
};
const first = await postSonarr(app, payload);
expect(first.status).toBe(200);
expect(first.body.duplicate).toBeUndefined();
const second = await postSonarr(app, payload);
expect(second.status).toBe(200);
expect(second.body.duplicate).toBe(true);
});
it('sonarr: event with different date is not considered a duplicate', async () => {
const app = makeApp();
const first = await postSonarr(app, {
eventType: 'Grab', instanceName: 'Main Sonarr', date: '2026-05-19T14:00:00.000Z'
});
expect(first.body.duplicate).toBeUndefined();
const second = await postSonarr(app, {
eventType: 'Grab', instanceName: 'Main Sonarr', date: '2026-05-19T14:01:00.000Z'
});
expect(second.body.duplicate).toBeUndefined();
});
it('radarr: second identical event returns duplicate:true', async () => {
const app = makeApp();
const payload = {
eventType: 'Download',
instanceName: 'Main Radarr',
date: '2026-05-19T15:00:00.000Z'
};
await postRadarr(app, payload);
const second = await postRadarr(app, payload);
expect(second.body.duplicate).toBe(true);
});
it('event without date field is never considered a duplicate', async () => {
const app = makeApp();
const payload = { eventType: 'Grab', instanceName: 'Main Sonarr' };
const first = await postSonarr(app, payload);
const second = await postSonarr(app, payload);
// Neither should be flagged as duplicate (no date = no replay key)
expect(first.body.duplicate).toBeUndefined();
expect(second.body.duplicate).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Webhook metrics (Phase 5.1 integration)
// ---------------------------------------------------------------------------
describe('Webhook metrics — cache.updateWebhookMetrics integration', () => {
it('sonarr: increments eventsReceived for a known instance', async () => {
const app = makeApp();
const instanceUrl = 'https://sonarr.test';
const before = cache.getWebhookMetrics(instanceUrl);
const countBefore = before ? before.eventsReceived : 0;
await postSonarr(app, {
eventType: 'Grab',
instanceName: 'Main Sonarr',
date: '2026-05-19T16:00:00.000Z'
});
const after = cache.getWebhookMetrics(instanceUrl);
expect(after.eventsReceived).toBe(countBefore + 1);
expect(after.lastWebhookTimestamp).toBeGreaterThan(0);
});
it('radarr: increments eventsReceived for a known instance', async () => {
const app = makeApp();
const instanceUrl = 'https://radarr.test';
const before = cache.getWebhookMetrics(instanceUrl);
const countBefore = before ? before.eventsReceived : 0;
await postRadarr(app, {
eventType: 'Download',
instanceName: 'Main Radarr',
date: '2026-05-19T16:01:00.000Z'
});
const after = cache.getWebhookMetrics(instanceUrl);
expect(after.eventsReceived).toBe(countBefore + 1);
});
it('does not crash when instanceName does not match a configured instance', async () => {
const app = makeApp();
const res = await postSonarr(app, {
eventType: 'Grab',
instanceName: 'Unknown Instance',
date: '2026-05-19T16:02:00.000Z'
});
expect(res.status).toBe(200);
expect(res.body.received).toBe(true);
});
it('global metrics totalWebhookEventsReceived increments after valid event', async () => {
const app = makeApp();
const beforeGlobal = cache.getGlobalWebhookMetrics();
const beforeCount = beforeGlobal.totalWebhookEventsReceived;
await postSonarr(app, {
eventType: 'Grab',
instanceName: 'Main Sonarr',
date: '2026-05-19T17:00:00.000Z'
});
const afterGlobal = cache.getGlobalWebhookMetrics();
expect(afterGlobal.totalWebhookEventsReceived).toBe(beforeCount + 1);
});
});
// ---------------------------------------------------------------------------
// Secret not included in response
// ---------------------------------------------------------------------------
describe('Security — secret never leaks', () => {
it('sonarr: SOFARR_WEBHOOK_SECRET is not present in any response body', async () => {
const app = makeApp();
const res = await postSonarr(app, {
eventType: 'Grab',
instanceName: 'Main Sonarr',
date: '2026-05-19T18:00:00.000Z'
});
expect(JSON.stringify(res.body)).not.toContain(VALID_SECRET);
});
it('radarr: SOFARR_WEBHOOK_SECRET is not present in 401 response body', async () => {
const app = makeApp();
const res = await postRadarr(app, RADARR_GRAB, 'wrong');
expect(JSON.stringify(res.body)).not.toContain(VALID_SECRET);
});
});
-28
View File
@@ -1,28 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { vi, beforeEach, afterEach } from 'vitest';
import os from 'os';
import path from 'path';
import fs from 'fs';
// Give each test worker a unique temp DATA_DIR so tokenStore file I/O is
// fully isolated and doesn't conflict with a running dev server's data/.
const tmpDir = path.join(os.tmpdir(), `sofarr-test-${process.pid}`);
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
process.env.DATA_DIR = tmpDir;
// Disable rate limiters in tests — all supertest requests share 127.0.0.1
// and would quickly exhaust per-IP windows otherwise.
process.env.SKIP_RATE_LIMIT = '1';
// Suppress console noise during tests (errors still surface via thrown exceptions)
beforeEach(() => {
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
// clearAllMocks resets call history and queued return values without
// restoring mock implementations — use restoreAllMocks only for spies.
vi.clearAllMocks();
});
-77
View File
@@ -1,77 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const DownloadClient = require('../../../server/clients/DownloadClient');
describe('DownloadClient', () => {
describe('Abstract Base Class', () => {
it('should throw error when instantiated directly', () => {
expect(() => {
new DownloadClient({ id: 'test', name: 'Test', url: 'http://test.com' });
}).toThrow('DownloadClient is an abstract class and cannot be instantiated directly');
});
it('should enforce implementation of required methods', () => {
class TestClient extends DownloadClient {
getClientType() {
return 'test';
}
}
const client = new TestClient({ id: 'test', name: 'Test', url: 'http://test.com' });
expect(() => client.testConnection()).rejects.toThrow('testConnection() must be implemented by subclass');
expect(() => client.getActiveDownloads()).rejects.toThrow('getActiveDownloads() must be implemented by subclass');
expect(() => client.normalizeDownload({})).toThrow('normalizeDownload() must be implemented by subclass');
});
});
describe('Base Properties', () => {
class TestClient extends DownloadClient {
getClientType() {
return 'test';
}
async testConnection() {
return true;
}
async getActiveDownloads() {
return [];
}
normalizeDownload(download) {
return download;
}
}
it('should set basic properties from config', () => {
const config = {
id: 'test-instance',
name: 'Test Instance',
url: 'http://test.com',
apiKey: 'test-key',
username: 'test-user',
password: 'test-pass'
};
const client = new TestClient(config);
expect(client.id).toBe('test-instance');
expect(client.name).toBe('Test Instance');
expect(client.url).toBe('http://test.com');
expect(client.apiKey).toBe('test-key');
expect(client.username).toBe('test-user');
expect(client.password).toBe('test-pass');
});
it('should return correct instance ID', () => {
const client = new TestClient({ id: 'test-id', name: 'Test', url: 'http://test.com' });
expect(client.getInstanceId()).toBe('test-id');
});
it('should have optional getClientStatus method returning null', async () => {
const client = new TestClient({ id: 'test', name: 'Test', url: 'http://test.com' });
const status = await client.getClientStatus();
expect(status).toBeNull();
});
});
});
@@ -1,208 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import QBittorrentClient from '../../../server/clients/QBittorrentClient.js';
import nock from 'nock';
import { vi } from 'vitest';
vi.mock('../../../server/utils/logger', () => ({
logToFile: vi.fn()
}));
describe('QBittorrentClient', () => {
let client;
let mockConfig;
beforeEach(() => {
mockConfig = {
id: 'test-qb',
name: 'Test qBittorrent',
url: 'http://localhost:8080',
username: 'admin',
password: 'adminadmin'
};
client = new QBittorrentClient(mockConfig);
// Clear all mocks
vi.clearAllMocks();
});
describe('Constructor', () => {
it('should initialize with correct properties', () => {
expect(client.getClientType()).toBe('qbittorrent');
expect(client.getInstanceId()).toBe('test-qb');
expect(client.name).toBe('Test qBittorrent');
expect(client.url).toBe('http://localhost:8080');
expect(client.authCookie).toBeNull();
expect(client.lastRid).toBe(0);
expect(client.torrentMap).toBeInstanceOf(Map);
expect(client.fallbackThisCycle).toBe(false);
});
});
describe('Authentication', () => {
it('should login successfully with valid credentials', async () => {
nock('http://localhost:8080')
.post('/api/v2/auth/login', 'username=admin&password=adminadmin')
.reply(200, {}, { 'set-cookie': ['SID=test-cookie'] });
const result = await client.login();
expect(result).toBe(true);
expect(client.authCookie).toBe('SID=test-cookie');
});
it('should handle login failure', async () => {
nock('http://localhost:8080')
.post('/api/v2/auth/login', 'username=admin&password=adminadmin')
.reply(200, {}, {});
const result = await client.login();
expect(result).toBe(false);
expect(client.authCookie).toBeNull();
});
it('should handle login error', async () => {
nock('http://localhost:8080')
.post('/api/v2/auth/login', 'username=admin&password=adminadmin')
.replyWithError(new Error('Network error'));
const result = await client.login();
expect(result).toBe(false);
expect(client.authCookie).toBeNull();
});
});
describe('Connection Test', () => {
it('should test connection successfully', async () => {
// Mock login success
client.login = vi.fn().mockResolvedValue(true);
// Mock version request
const mockResponse = { data: 'v4.3.5' };
client.makeRequest = vi.fn().mockResolvedValue(mockResponse);
const result = await client.testConnection();
expect(result).toBe(true);
expect(client.makeRequest).toHaveBeenCalledWith('/api/v2/app/version');
});
it('should handle connection test failure', async () => {
client.login = vi.fn().mockRejectedValue(new Error('Auth failed'));
const result = await client.testConnection();
expect(result).toBe(false);
});
});
describe('Download Normalization', () => {
it('should normalize torrent data correctly', () => {
const torrent = {
hash: 'abc123',
name: 'Test Torrent',
state: 'downloading',
progress: 0.75,
size: 1000000000,
completed: 750000000,
dlspeed: 1048576,
eta: 3600,
category: 'movies',
tags: 'movie,hd',
content_path: '/downloads/test',
added_on: 1640995200
};
const normalized = client.normalizeDownload(torrent);
expect(normalized).toEqual({
id: 'abc123',
title: 'Test Torrent',
type: 'torrent',
client: 'qbittorrent',
instanceId: 'test-qb',
instanceName: 'Test qBittorrent',
status: 'Downloading',
progress: 75,
size: 1000000000,
downloaded: 750000000,
speed: 1048576,
eta: 3600,
category: 'movies',
tags: ['movie', 'hd'],
savePath: '/downloads/test',
addedOn: '2022-01-01T00:00:00.000Z',
raw: torrent
});
});
it('should handle unknown torrent states', () => {
const torrent = {
hash: 'abc123',
name: 'Test Torrent',
state: 'unknown_state',
progress: 0.5,
size: 1000000,
completed: 500000,
dlspeed: 0,
eta: -1
};
const normalized = client.normalizeDownload(torrent);
expect(normalized.status).toBe('unknown_state');
expect(normalized.eta).toBeNull();
});
it('should handle missing completed field', () => {
const torrent = {
hash: 'abc123',
name: 'Test Torrent',
state: 'downloading',
progress: 0.5,
size: 1000000,
dlspeed: 0
};
const normalized = client.normalizeDownload(torrent);
expect(normalized.downloaded).toBe(500000);
});
});
describe('Fallback Flag Management', () => {
it('should reset fallback flag', () => {
client.fallbackThisCycle = true;
client.resetFallbackFlag();
expect(client.fallbackThisCycle).toBe(false);
});
});
describe('Error Handling', () => {
it('should handle makeRequest authentication failure', async () => {
client.authCookie = 'invalid-cookie';
// First request fails with 403
nock('http://localhost:8080')
.get('/test')
.reply(403, {});
// Re-authentication succeeds
nock('http://localhost:8080')
.post('/api/v2/auth/login', 'username=admin&password=adminadmin')
.reply(200, {}, { 'set-cookie': ['SID=new-cookie'] });
// Retry succeeds
nock('http://localhost:8080')
.get('/test')
.reply(200, { data: 'success' });
const result = await client.makeRequest('/test');
expect(result.data).toEqual({ data: 'success' });
expect(client.authCookie).toBe('SID=new-cookie');
});
});
});
-423
View File
@@ -1,423 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import RTorrentClient from '../../../server/clients/RTorrentClient.js';
import { vi } from 'vitest';
vi.mock('../../../server/utils/logger', () => ({
logToFile: vi.fn()
}));
describe('RTorrentClient', () => {
let client;
let mockConfig;
let mockMethodCall;
beforeEach(() => {
mockMethodCall = vi.fn();
mockConfig = {
id: 'test-rtorrent',
name: 'Test rTorrent',
url: 'http://localhost:8080',
username: 'rtorrent',
password: 'rtorrent'
};
client = new RTorrentClient(mockConfig);
// Mock the xmlrpc client's methodCall directly
client.client.methodCall = mockMethodCall;
});
describe('Constructor', () => {
it('should initialize with correct properties', () => {
expect(client.getClientType()).toBe('rtorrent');
expect(client.getInstanceId()).toBe('test-rtorrent');
expect(client.name).toBe('Test rTorrent');
expect(client.url).toBe('http://localhost:8080');
});
it('should create xmlrpc client with correct URL', async () => {
expect(client.url).toBe('http://localhost:8080');
expect(client.client).toBeDefined();
});
it('should create xmlrpc client without auth when no credentials', () => {
const noAuthConfig = {
id: 'test-rtorrent-noauth',
name: 'Test rTorrent No Auth',
url: 'http://localhost:8080/RPC2'
};
const clientNoAuth = new RTorrentClient(noAuthConfig);
expect(clientNoAuth.client).toBeDefined();
});
it('should use whatbox.ca-style /xmlrpc path exactly as configured', () => {
const whatboxConfig = {
id: 'test-whatbox',
name: 'Whatbox',
url: 'https://user.whatbox.ca/xmlrpc',
username: 'user',
password: 'pass'
};
const clientWhatbox = new RTorrentClient(whatboxConfig);
expect(clientWhatbox.client).toBeDefined();
});
it('should use custom RPC path exactly as configured', () => {
const customConfig = {
id: 'test-custom',
name: 'Custom',
url: 'https://example.com/custom/rpc/path'
};
const clientCustom = new RTorrentClient(customConfig);
expect(clientCustom.client).toBeDefined();
});
});
describe('Connection Test', () => {
it('should test connection successfully', async () => {
mockMethodCall.mockImplementation((method, params, callback) => {
callback(null, '0.9.8');
});
const result = await client.testConnection();
expect(result).toBe(true);
});
it('should handle connection test failure', async () => {
mockMethodCall.mockImplementation((method, params, callback) => {
callback(new Error('Connection refused'));
});
const result = await client.testConnection();
expect(result).toBe(false);
});
});
describe('getActiveDownloads', () => {
it('should fetch and normalize torrents', async () => {
const mockTorrents = [
[
'abc123def456',
'Test Torrent 1',
1000000000,
750000000,
1048576,
0,
1,
1,
0,
'/downloads/test',
'movies'
],
[
'def789abc012',
'Test Torrent 2',
2000000000,
2000000000,
0,
512000,
1,
1,
0,
'/downloads/complete',
'tv'
]
];
mockMethodCall.mockImplementation((method, params, callback) => {
callback(null, mockTorrents);
});
const downloads = await client.getActiveDownloads();
expect(downloads).toHaveLength(2);
expect(downloads[0].id).toBe('abc123def456');
expect(downloads[0].title).toBe('Test Torrent 1');
expect(downloads[0].status).toBe('Downloading');
expect(downloads[0].progress).toBe(75);
expect(downloads[0].category).toBe('movies');
expect(downloads[1].status).toBe('Seeding');
expect(downloads[1].category).toBe('tv');
});
it('should handle empty torrent list', async () => {
mockMethodCall.mockImplementation((method, params, callback) => {
callback(null, []);
});
const downloads = await client.getActiveDownloads();
expect(downloads).toEqual([]);
});
it('should handle XML-RPC errors gracefully', async () => {
mockMethodCall.mockImplementation((method, params, callback) => {
callback(new Error('XML-RPC fault'));
});
const downloads = await client.getActiveDownloads();
expect(downloads).toEqual([]);
});
});
describe('normalizeDownload', () => {
it('should normalize a downloading torrent', () => {
const torrent = [
'hash123',
'Downloading Torrent',
1000000000,
500000000,
1048576,
0,
1,
1,
0,
'/downloads',
''
];
const normalized = client.normalizeDownload(torrent);
expect(normalized).toEqual({
id: 'hash123',
title: 'Downloading Torrent',
type: 'torrent',
client: 'rtorrent',
instanceId: 'test-rtorrent',
instanceName: 'Test rTorrent',
status: 'Downloading',
progress: 50,
size: 1000000000,
downloaded: 500000000,
speed: 1048576,
eta: 477,
category: undefined,
tags: [],
savePath: '/downloads',
addedOn: undefined,
arrQueueId: undefined,
arrType: undefined,
raw: torrent
});
});
it('should normalize a seeding torrent', () => {
const torrent = [
'hash456',
'Seeding Torrent',
500000000,
500000000,
0,
204800,
1,
1,
0,
'/downloads/complete',
'movies'
];
const normalized = client.normalizeDownload(torrent);
expect(normalized.status).toBe('Seeding');
expect(normalized.progress).toBe(100);
expect(normalized.speed).toBe(204800);
expect(normalized.eta).toBeNull();
expect(normalized.category).toBe('movies');
expect(normalized.tags).toEqual(['movies']);
});
it('should normalize a paused torrent', () => {
const torrent = [
'hash789',
'Paused Torrent',
1000000000,
250000000,
0,
0,
1,
0,
0,
'/downloads',
''
];
const normalized = client.normalizeDownload(torrent);
expect(normalized.status).toBe('Paused');
expect(normalized.speed).toBe(0);
expect(normalized.eta).toBeNull();
});
it('should normalize a stopped torrent', () => {
const torrent = [
'hashabc',
'Stopped Torrent',
1000000000,
0,
0,
0,
0,
0,
0,
'/downloads',
''
];
const normalized = client.normalizeDownload(torrent);
expect(normalized.status).toBe('Stopped');
});
it('should normalize a checking torrent', () => {
const torrent = [
'hashdef',
'Checking Torrent',
1000000000,
500000000,
0,
0,
1,
0,
1,
'/downloads',
''
];
const normalized = client.normalizeDownload(torrent);
expect(normalized.status).toBe('Checking');
});
it('should handle zero-size torrent', () => {
const torrent = [
'hash000',
'Zero Size',
0,
0,
0,
0,
1,
1,
0,
'/downloads',
''
];
const normalized = client.normalizeDownload(torrent);
expect(normalized.progress).toBe(0);
expect(normalized.size).toBe(0);
expect(normalized.downloaded).toBe(0);
});
});
describe('Status Mapping', () => {
const testCases = [
{ state: 0, isActive: 0, isHashChecking: 0, completed: 0, size: 100, expected: 'Stopped' },
{ state: 1, isActive: 1, isHashChecking: 0, completed: 50, size: 100, expected: 'Downloading' },
{ state: 1, isActive: 1, isHashChecking: 0, completed: 100, size: 100, expected: 'Seeding' },
{ state: 1, isActive: 0, isHashChecking: 0, completed: 50, size: 100, expected: 'Paused' },
{ state: 1, isActive: 0, isHashChecking: 0, completed: 100, size: 100, expected: 'Paused' },
{ state: 1, isActive: 0, isHashChecking: 1, completed: 50, size: 100, expected: 'Checking' },
{ state: 1, isActive: 1, isHashChecking: 1, completed: 50, size: 100, expected: 'Checking' }
];
testCases.forEach(({ state, isActive, isHashChecking, completed, size, expected }) => {
it(`should map state=${state} isActive=${isActive} isHashChecking=${isHashChecking} to ${expected}`, () => {
const status = client._mapStatus(state, isActive, isHashChecking, completed, size);
expect(status).toBe(expected);
});
});
});
describe('ARR Info Extraction', () => {
it('should extract series info from filename', () => {
const torrent = [
'hash123',
'Show Name - S01E02 - Episode Title',
1000000000,
500000000,
1048576,
0,
1,
1,
0,
'/downloads',
''
];
const normalized = client.normalizeDownload(torrent);
expect(normalized.arrType).toBe('series');
});
it('should extract movie info from filename', () => {
const torrent = [
'hash456',
'Movie Title (2023) 1080p',
2000000000,
1000000000,
1048576,
0,
1,
1,
0,
'/downloads',
''
];
const normalized = client.normalizeDownload(torrent);
expect(normalized.arrType).toBe('movie');
});
it('should not extract ARR info from generic filename', () => {
const torrent = [
'hash789',
'Generic File Name.mkv',
1000000000,
500000000,
1048576,
0,
1,
1,
0,
'/downloads',
''
];
const normalized = client.normalizeDownload(torrent);
expect(normalized.arrType).toBeUndefined();
});
});
describe('Client Status', () => {
it('should get client status', async () => {
mockMethodCall.mockImplementation((method, params, callback) => {
if (method === 'throttle.global_down.rate') {
callback(null, 1048576);
} else if (method === 'throttle.global_up.rate') {
callback(null, 512000);
}
});
const status = await client.getClientStatus();
expect(status).toEqual({
globalDownRate: 1048576,
globalUpRate: 512000
});
});
it('should handle status request errors', async () => {
mockMethodCall.mockImplementation((method, params, callback) => {
callback(new Error('Status error'));
});
const status = await client.getClientStatus();
expect(status).toBeNull();
});
});
});
-302
View File
@@ -1,302 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import SABnzbdClient from '../../../server/clients/SABnzbdClient.js';
import nock from 'nock';
import { vi } from 'vitest';
vi.mock('../../../server/utils/logger', () => ({
logToFile: vi.fn()
}));
describe('SABnzbdClient', () => {
let client;
let mockConfig;
beforeEach(() => {
mockConfig = {
id: 'test-sab',
name: 'Test SABnzbd',
url: 'http://localhost:8080',
apiKey: 'test-api-key'
};
client = new SABnzbdClient(mockConfig);
// Clear all mocks
vi.clearAllMocks();
});
describe('Constructor', () => {
it('should initialize with correct properties', () => {
expect(client.getClientType()).toBe('sabnzbd');
expect(client.getInstanceId()).toBe('test-sab');
expect(client.name).toBe('Test SABnzbd');
expect(client.url).toBe('http://localhost:8080');
expect(client.apiKey).toBe('test-api-key');
});
});
describe('Connection Test', () => {
it('should test connection successfully', async () => {
const mockResponse = {
data: { version: '3.6.1' }
};
client.makeRequest = vi.fn().mockResolvedValue(mockResponse);
const result = await client.testConnection();
expect(result).toBe(true);
expect(client.makeRequest).toHaveBeenCalledWith('', { mode: 'version' });
});
it('should handle connection test failure', async () => {
client.makeRequest = vi.fn().mockRejectedValue(new Error('Connection failed'));
const result = await client.testConnection();
expect(result).toBe(false);
});
});
describe('API Requests', () => {
it('should make API request with correct parameters', async () => {
nock('http://localhost:8080')
.get('/api')
.query({
output: 'json',
apikey: 'test-api-key',
mode: 'queue',
limit: 10
})
.reply(200, { result: 'success' });
const result = await client.makeRequest({ mode: 'queue', limit: 10 });
expect(result.data).toEqual({ result: 'success' });
});
it('should handle API request errors', async () => {
nock('http://localhost:8080')
.get('/api')
.query({ output: 'json', apikey: 'test-api-key', mode: 'queue' })
.replyWithError(new Error('API Error'));
await expect(client.makeRequest({ mode: 'queue' })).rejects.toThrow('API Error');
});
});
describe('Download Normalization', () => {
it('should normalize queue download correctly', () => {
const slot = {
nzo_id: 'test123',
filename: 'Test Movie.mkv',
status: 'Downloading',
progress: 75.5,
mb: 1000,
mbleft: 245,
kbpersec: 1024,
timeleft: '0:15:30',
cat: 'movies',
labels: 'movie,hd',
added: 1640995200
};
const normalized = client.normalizeDownload(slot, 'queue');
expect(normalized).toEqual({
id: 'test123',
title: 'Test Movie.mkv',
type: 'usenet',
client: 'sabnzbd',
instanceId: 'test-sab',
instanceName: 'Test SABnzbd',
status: 'Downloading',
progress: 76,
size: 1000 * 1024 * 1024,
downloaded: 755 * 1024 * 1024,
speed: 1024 * 1024,
eta: 930, // 15 minutes 30 seconds
category: 'movies',
tags: ['movie', 'hd'],
savePath: undefined,
addedOn: '2022-01-01T00:00:00.000Z',
raw: { ...slot, source: 'queue' }
});
});
it('should normalize history download correctly', () => {
const slot = {
nzo_id: 'test456',
filename: 'Test Series S01E01.mkv',
status: 'Completed',
mb: 500,
mbleft: 0,
cat: 'tv',
added: 1640995200
};
const normalized = client.normalizeDownload(slot, 'history');
expect(normalized.status).toBe('Completed');
expect(normalized.progress).toBe(100);
expect(normalized.downloaded).toBe(500 * 1024 * 1024);
expect(normalized.speed).toBe(0);
expect(normalized.eta).toBeNull();
expect(normalized.raw.source).toBe('history');
});
it('should parse time strings correctly', () => {
const testCases = [
{ input: '0:05:30', expected: 330 }, // 5m 30s
{ input: '15:30', expected: 930 }, // 15m 30s
{ input: '330', expected: 330 }, // 330 seconds
{ input: 'unknown', expected: null }, // unknown
{ input: '0:00', expected: null } // zero
];
testCases.forEach(({ input, expected }) => {
const slot = {
nzo_id: 'test',
filename: 'Test',
status: 'Downloading',
progress: 50,
mb: 1000,
mbleft: 500,
timeleft: input
};
const normalized = client.normalizeDownload(slot, 'queue');
expect(normalized.eta).toBe(expected);
});
});
it('should extract Sonarr/Radarr info from filename', () => {
const testCases = [
{ filename: 'Show Name - S01E02 - Episode Title', expectedType: 'series' },
{ filename: 'Movie Title (2023) 1080p', expectedType: 'movie' },
{ filename: 'Random File Name.mkv', expectedType: undefined }
];
testCases.forEach(({ filename, expectedType }) => {
const slot = {
nzo_id: 'test',
filename: filename,
status: 'Downloading',
progress: 50,
mb: 1000,
mbleft: 500
};
const normalized = client.normalizeDownload(slot, 'queue');
expect(normalized.arrType).toBe(expectedType);
});
});
it('should handle size parsing from strings', () => {
const slot = {
nzo_id: 'test',
filename: 'Test',
status: 'Downloading',
progress: 50,
size: '1.5 GB',
sizeleft: '0.75 GB'
};
const normalized = client.normalizeDownload(slot, 'queue');
expect(normalized.size).toBe(1.5 * 1024 * 1024 * 1024);
expect(normalized.downloaded).toBe(0.75 * 1024 * 1024 * 1024);
expect(normalized.progress).toBe(50);
});
});
describe('Unit Multipliers', () => {
it('should return correct multipliers for different units', () => {
expect(client.getUnitMultiplier('b')).toBe(1);
expect(client.getUnitMultiplier('KB')).toBe(1024);
expect(client.getUnitMultiplier('mb')).toBe(1024 * 1024);
expect(client.getUnitMultiplier('GB')).toBe(1024 * 1024 * 1024);
expect(client.getUnitMultiplier('tb')).toBe(1024 * 1024 * 1024 * 1024);
expect(client.getUnitMultiplier('unknown')).toBe(1);
});
});
describe('Active Downloads', () => {
it('should fetch and normalize downloads from queue and history', async () => {
const mockQueueResponse = {
data: {
queue: {
slots: [
{ nzo_id: 'queue1', filename: 'Queue Item', status: 'Downloading' }
]
}
}
};
const mockHistoryResponse = {
data: {
history: {
slots: [
{ nzo_id: 'hist1', filename: 'History Item', status: 'Completed' }
]
}
}
};
client.makeRequest = vi.fn()
.mockResolvedValueOnce(mockQueueResponse)
.mockResolvedValueOnce(mockHistoryResponse);
const downloads = await client.getActiveDownloads();
expect(client.makeRequest).toHaveBeenCalledWith({ mode: 'queue' });
expect(client.makeRequest).toHaveBeenCalledWith({ mode: 'history', limit: 10 });
expect(downloads).toHaveLength(2);
expect(downloads[0].id).toBe('queue1');
expect(downloads[1].id).toBe('hist1');
});
it('should handle API errors gracefully', async () => {
client.makeRequest = vi.fn().mockRejectedValue(new Error('API Error'));
const downloads = await client.getActiveDownloads();
expect(downloads).toEqual([]);
});
});
describe('Client Status', () => {
it('should get client status', async () => {
const mockResponse = {
data: {
queue: {
status: 'Active',
speed: 1048576,
kbpersec: 1024,
sizeleft: 500000000,
mbleft: 500
}
}
};
client.makeRequest = vi.fn().mockResolvedValue(mockResponse);
const status = await client.getClientStatus();
expect(status).toEqual({
status: 'Active',
speed: 1048576,
kbpersec: 1024,
sizeleft: 500000000,
mbleft: 500
});
});
it('should handle status request errors', async () => {
client.makeRequest = vi.fn().mockRejectedValue(new Error('Status error'));
const status = await client.getClientStatus();
expect(status).toBeNull();
});
});
});
@@ -1,436 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import TransmissionClient from '../../../server/clients/TransmissionClient.js';
import nock from 'nock';
import { vi } from 'vitest';
vi.mock('../../../server/utils/logger', () => ({
logToFile: vi.fn()
}));
describe('TransmissionClient', () => {
let client;
let mockConfig;
beforeEach(() => {
mockConfig = {
id: 'test-transmission',
name: 'Test Transmission',
url: 'http://localhost:9091',
username: 'transmission',
password: 'transmission'
};
client = new TransmissionClient(mockConfig);
// Clear all mocks
vi.clearAllMocks();
});
describe('Constructor', () => {
it('should initialize with correct properties', () => {
expect(client.getClientType()).toBe('transmission');
expect(client.getInstanceId()).toBe('test-transmission');
expect(client.name).toBe('Test Transmission');
expect(client.url).toBe('http://localhost:9091');
expect(client.sessionId).toBeNull();
expect(client.rpcUrl).toBe('http://localhost:9091/transmission/rpc');
});
});
describe('Connection Test', () => {
it('should test connection successfully', async () => {
const mockResponse = {
data: { result: 'success', arguments: {} }
};
client.makeRequest = vi.fn().mockResolvedValue(mockResponse);
const result = await client.testConnection();
expect(result).toBe(true);
expect(client.makeRequest).toHaveBeenCalledWith('session-get');
});
it('should handle connection test failure', async () => {
client.makeRequest = vi.fn().mockRejectedValue(new Error('Connection failed'));
const result = await client.testConnection();
expect(result).toBe(false);
});
});
describe('RPC Requests', () => {
it('should make RPC request with session ID', async () => {
client.sessionId = 'test-session-id';
nock('http://localhost:9091')
.post('/transmission/rpc', {
method: 'torrent-get',
arguments: { fields: ['id', 'name'] }
})
.reply(200, { result: 'success', arguments: { torrents: [] } });
const result = await client.makeRequest('torrent-get', { fields: ['id', 'name'] });
expect(result.data).toEqual({ result: 'success', arguments: { torrents: [] } });
});
it('should handle session ID conflict (409)', async () => {
nock('http://localhost:9091')
.post('/transmission/rpc', { method: 'session-get', arguments: {} })
.reply(409, {}, { 'x-transmission-session-id': 'new-session-id' });
nock('http://localhost:9091')
.post('/transmission/rpc', { method: 'session-get', arguments: {} })
.reply(200, { result: 'success', arguments: {} });
const result = await client.makeRequest('session-get');
expect(client.sessionId).toBe('new-session-id');
expect(result.data).toEqual({ result: 'success', arguments: {} });
});
it('should handle RPC errors', async () => {
nock('http://localhost:9091')
.post('/transmission/rpc', { method: 'invalid-method', arguments: {} })
.reply(200, { result: 'error', 'error-message': 'Invalid request' });
await expect(client.makeRequest('invalid-method')).rejects.toThrow('Transmission RPC error: error');
});
});
describe('Download Normalization', () => {
it('should normalize torrent data correctly', () => {
const torrent = {
id: 1,
hashString: 'abc123',
name: 'Test Torrent',
status: 4, // downloading
totalSize: 1000000000,
sizeWhenDone: 1000000000,
leftUntilDone: 250000000,
rateDownload: 1048576,
rateUpload: 0,
eta: 3600,
downloadedEver: 750000000,
uploadedEver: 0,
percentDone: 0.75,
addedDate: 1640995200,
doneDate: 0,
labels: ['movies', 'hd'],
downloadDir: '/downloads/test'
};
const normalized = client.normalizeDownload(torrent);
expect(normalized).toEqual({
id: 'abc123',
title: 'Test Torrent',
type: 'torrent',
client: 'transmission',
instanceId: 'test-transmission',
instanceName: 'Test Transmission',
status: 'Downloading',
progress: 75,
size: 1000000000,
downloaded: 750000000,
speed: 1048576,
eta: 3600,
category: 'movies',
tags: ['movies', 'hd'],
savePath: '/downloads/test',
addedOn: '2022-01-01T00:00:00.000Z',
raw: torrent
});
});
it('should handle different torrent statuses', () => {
const statusMap = {
0: 'Stopped',
1: 'Queued',
2: 'Checking',
3: 'Queued',
4: 'Downloading',
5: 'Queued',
6: 'Seeding',
7: 'Unknown'
};
Object.entries(statusMap).forEach(([status, expectedStatus]) => {
const torrent = {
id: 1,
hashString: 'abc123',
name: 'Test',
status: parseInt(status),
totalSize: 1000000,
sizeWhenDone: 1000000,
leftUntilDone: 500000,
rateDownload: 0,
eta: -1,
percentDone: 0.5,
labels: []
};
const normalized = client.normalizeDownload(torrent);
expect(normalized.status).toBe(expectedStatus);
});
});
it('should handle unknown ETA values', () => {
const testCases = [
{ eta: -1, expected: null },
{ eta: -2, expected: null },
{ eta: 3600, expected: 3600 }
];
testCases.forEach(({ eta, expected }) => {
const torrent = {
id: 1,
hashString: 'abc123',
name: 'Test',
status: 4,
totalSize: 1000000,
sizeWhenDone: 1000000,
leftUntilDone: 500000,
rateDownload: 0,
eta: eta,
percentDone: 0.5,
labels: []
};
const normalized = client.normalizeDownload(torrent);
expect(normalized.eta).toBe(expected);
});
});
it('should extract category from first label', () => {
const torrent = {
id: 1,
hashString: 'abc123',
name: 'Test',
status: 4,
totalSize: 1000000,
sizeWhenDone: 1000000,
leftUntilDone: 500000,
rateDownload: 0,
eta: -1,
percentDone: 0.5,
labels: ['movies', 'hd', '4k']
};
const normalized = client.normalizeDownload(torrent);
expect(normalized.category).toBe('movies');
expect(normalized.tags).toEqual(['movies', 'hd', '4k']);
});
it('should handle empty labels', () => {
const torrent = {
id: 1,
hashString: 'abc123',
name: 'Test',
status: 4,
totalSize: 1000000,
sizeWhenDone: 1000000,
leftUntilDone: 500000,
rateDownload: 0,
eta: -1,
percentDone: 0.5,
labels: []
};
const normalized = client.normalizeDownload(torrent);
expect(normalized.category).toBeUndefined();
expect(normalized.tags).toEqual([]);
});
});
describe('Active Downloads', () => {
it('should fetch and normalize torrents', async () => {
const mockResponse = {
data: {
result: 'success',
arguments: {
torrents: [
{
id: 1,
hashString: 'abc123',
name: 'Test Torrent 1',
status: 4,
totalSize: 1000000,
sizeWhenDone: 1000000,
leftUntilDone: 500000,
rateDownload: 1048576,
eta: 3600,
percentDone: 0.5,
labels: ['test']
},
{
id: 2,
hashString: 'def456',
name: 'Test Torrent 2',
status: 6,
totalSize: 2000000,
sizeWhenDone: 2000000,
leftUntilDone: 0,
rateDownload: 0,
eta: -1,
percentDone: 1.0,
labels: []
}
]
}
}
};
client.makeRequest = vi.fn().mockResolvedValue(mockResponse);
const downloads = await client.getActiveDownloads();
expect(client.makeRequest).toHaveBeenCalledWith('torrent-get', {
fields: [
'id', 'name', 'hashString', 'status', 'totalSize', 'sizeWhenDone',
'leftUntilDone', 'rateDownload', 'rateUpload', 'eta', 'downloadedEver',
'uploadedEver', 'percentDone', 'addedDate', 'doneDate', 'trackerStats',
'labels', 'downloadDir', 'error', 'errorString', 'peersConnected',
'peersGettingFromUs', 'peersSendingToUs', 'queuePosition'
]
});
expect(downloads).toHaveLength(2);
expect(downloads[0].title).toBe('Test Torrent 1');
expect(downloads[0].status).toBe('Downloading');
expect(downloads[1].title).toBe('Test Torrent 2');
expect(downloads[1].status).toBe('Seeding');
});
it('should handle API errors gracefully', async () => {
client.makeRequest = vi.fn().mockRejectedValue(new Error('API Error'));
const downloads = await client.getActiveDownloads();
expect(downloads).toEqual([]);
});
it('should handle empty torrent list', async () => {
const mockResponse = {
data: {
result: 'success',
arguments: { torrents: [] }
}
};
client.makeRequest = vi.fn().mockResolvedValue(mockResponse);
const downloads = await client.getActiveDownloads();
expect(downloads).toEqual([]);
});
});
describe('Client Status', () => {
it('should get client status and session stats', async () => {
const mockSessionResponse = {
data: {
result: 'success',
arguments: {
'download-dir': '/downloads',
'peer-port': 51413,
'rpc-version': 15
}
}
};
const mockStatsResponse = {
data: {
result: 'success',
arguments: {
'downloaded-bytes': 1000000000,
'uploaded-bytes': 500000000,
'torrent-count': 5
}
}
};
client.makeRequest = vi.fn()
.mockResolvedValueOnce(mockSessionResponse)
.mockResolvedValueOnce(mockStatsResponse);
const status = await client.getClientStatus();
expect(client.makeRequest).toHaveBeenCalledWith('session-get');
expect(client.makeRequest).toHaveBeenCalledWith('session-stats');
expect(status).toEqual({
session: mockSessionResponse.data.arguments,
stats: mockStatsResponse.data.arguments
});
});
it('should handle status request errors', async () => {
client.makeRequest = vi.fn().mockRejectedValue(new Error('Status error'));
const status = await client.getClientStatus();
expect(status).toBeNull();
});
});
describe('ARR Info Extraction', () => {
it('should extract series info from filename', () => {
const torrent = {
id: 1,
hashString: 'abc123',
name: 'Show Name - S01E02 - Episode Title',
status: 4,
totalSize: 1000000,
sizeWhenDone: 1000000,
leftUntilDone: 500000,
rateDownload: 0,
eta: -1,
percentDone: 0.5,
labels: []
};
const normalized = client.normalizeDownload(torrent);
expect(normalized.arrType).toBe('series');
});
it('should extract movie info from filename', () => {
const torrent = {
id: 1,
hashString: 'abc123',
name: 'Movie Title (2023) 1080p',
status: 4,
totalSize: 1000000,
sizeWhenDone: 1000000,
leftUntilDone: 500000,
rateDownload: 0,
eta: -1,
percentDone: 0.5,
labels: []
};
const normalized = client.normalizeDownload(torrent);
expect(normalized.arrType).toBe('movie');
});
it('should not extract ARR info from generic filename', () => {
const torrent = {
id: 1,
hashString: 'abc123',
name: 'Generic File Name.mkv',
status: 4,
totalSize: 1000000,
sizeWhenDone: 1000000,
leftUntilDone: 500000,
rateDownload: 0,
eta: -1,
percentDone: 0.5,
labels: []
};
const normalized = client.normalizeDownload(torrent);
expect(normalized.arrType).toBeUndefined();
});
});
});
-109
View File
@@ -1,109 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Tests for server/utils/config.js
*
* Verifies that instance config is parsed correctly from both the modern JSON
* array format and the legacy single-instance env var format. This is critical
* because misconfigured instances silently return no data rather than crashing.
*/
import { parseInstances, getSonarrInstances, getRadarrInstances } from '../../server/utils/config.js';
describe('parseInstances', () => {
describe('JSON array format', () => {
it('parses a valid single-instance JSON array', () => {
const json = JSON.stringify([{ name: 'main', url: 'https://sonarr.local', apiKey: 'abc123' }]);
const result = parseInstances(json, null, null);
expect(result).toHaveLength(1);
expect(result[0].url).toBe('https://sonarr.local');
expect(result[0].apiKey).toBe('abc123');
});
it('parses multiple instances', () => {
const json = JSON.stringify([
{ name: 'main', url: 'https://s1.local', apiKey: 'key1' },
{ name: 'backup', url: 'https://s2.local', apiKey: 'key2' }
]);
const result = parseInstances(json, null, null);
expect(result).toHaveLength(2);
expect(result[1].name).toBe('backup');
});
it('adds id from name when present', () => {
const json = JSON.stringify([{ name: 'i3omb', url: 'https://s.local', apiKey: 'k' }]);
const result = parseInstances(json, null, null);
expect(result[0].id).toBe('i3omb');
});
it('generates fallback id when name is absent', () => {
const json = JSON.stringify([{ url: 'https://s.local', apiKey: 'k' }]);
const result = parseInstances(json, null, null);
expect(result[0].id).toBe('instance-1');
});
it('handles multi-line JSON by stripping whitespace', () => {
const json = `[
{
"name": "main",
"url": "https://sonarr.local",
"apiKey": "abc"
}
]`;
const result = parseInstances(json, null, null);
expect(result).toHaveLength(1);
});
it('returns empty array for empty JSON array', () => {
expect(parseInstances('[]', null, null)).toEqual([]);
});
it('falls back to legacy format when JSON is malformed', () => {
const result = parseInstances('not-json', 'https://legacy.local', 'legacyKey');
expect(result).toHaveLength(1);
expect(result[0].url).toBe('https://legacy.local');
});
});
describe('legacy single-instance format', () => {
it('returns single instance from legacy URL + key', () => {
const result = parseInstances(null, 'https://sonarr.local', 'legacyapikey');
expect(result).toHaveLength(1);
expect(result[0].id).toBe('default');
expect(result[0].name).toBe('Default');
expect(result[0].url).toBe('https://sonarr.local');
expect(result[0].apiKey).toBe('legacyapikey');
});
it('returns empty array for qBittorrent with no apiKey and no JSON (legacy requires key)', () => {
// parseInstances requires legacyKey to be truthy for the legacy path;
// qBittorrent uses JSON array format, not the legacy URL+key path.
const result = parseInstances(null, 'https://qbt.local', null, 'admin', 'pass123');
expect(result).toEqual([]);
});
it('returns empty array when both JSON and legacy URL are missing', () => {
expect(parseInstances(null, null, null)).toEqual([]);
});
it('returns empty array when URL is set but key is missing', () => {
expect(parseInstances(null, 'https://sonarr.local', null)).toEqual([]);
});
});
describe('env-based getters', () => {
it('getSonarrInstances reads SONARR_INSTANCES from env', () => {
process.env.SONARR_INSTANCES = JSON.stringify([{ name: 'test', url: 'https://s.local', apiKey: 'k' }]);
const result = getSonarrInstances();
expect(result).toHaveLength(1);
expect(result[0].name).toBe('test');
delete process.env.SONARR_INSTANCES;
});
it('getRadarrInstances returns empty array when unconfigured', () => {
delete process.env.RADARR_INSTANCES;
delete process.env.RADARR_URL;
const result = getRadarrInstances();
expect(result).toEqual([]);
});
});
});
-347
View File
@@ -1,347 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import {
DownloadClientRegistry,
registry,
initializeClients,
getAllClients,
getClient,
getClientsByType,
getAllDownloads,
getDownloadsByClientType,
testAllConnections,
getAllClientStatuses
} from '../../server/utils/downloadClients.js';
import * as mockConfig from '../../server/utils/config.js';
import { vi } from 'vitest';
// Mock config and clients
vi.mock('../../server/utils/config', () => ({
getSABnzbdInstances: vi.fn(),
getQbittorrentInstances: vi.fn(),
getTransmissionInstances: vi.fn(),
getRtorrentInstances: vi.fn()
}));
vi.mock('../../server/utils/logger', () => ({
logToFile: vi.fn()
}));
vi.mock('../../server/clients/SABnzbdClient', () => {
return vi.fn().mockImplementation((config) => ({
getClientType: () => 'sabnzbd',
getInstanceId: () => config.id,
name: config.name,
getActiveDownloads: vi.fn().mockResolvedValue([
{ id: 'sab1', title: 'SAB Download 1', client: 'sabnzbd' }
]),
testConnection: vi.fn().mockResolvedValue(true),
getClientStatus: vi.fn().mockResolvedValue({ status: 'active' })
}));
});
vi.mock('../../server/clients/QBittorrentClient', () => {
return vi.fn().mockImplementation((config) => ({
getClientType: () => 'qbittorrent',
getInstanceId: () => config.id,
name: config.name,
getActiveDownloads: vi.fn().mockResolvedValue([
{ id: 'qb1', title: 'QB Download 1', client: 'qbittorrent' }
]),
testConnection: vi.fn().mockResolvedValue(true),
getClientStatus: vi.fn().mockResolvedValue({ status: 'active' }),
resetFallbackFlag: vi.fn()
}));
});
vi.mock('../../server/clients/TransmissionClient', () => {
return vi.fn().mockImplementation((config) => ({
getClientType: () => 'transmission',
getInstanceId: () => config.id,
name: config.name,
getActiveDownloads: vi.fn().mockResolvedValue([
{ id: 'trans1', title: 'Trans Download 1', client: 'transmission' }
]),
testConnection: vi.fn().mockResolvedValue(true),
getClientStatus: vi.fn().mockResolvedValue({ status: 'active' })
}));
});
vi.mock('../../server/clients/RTorrentClient', () => {
return vi.fn().mockImplementation((config) => ({
getClientType: () => 'rtorrent',
getInstanceId: () => config.id,
name: config.name,
getActiveDownloads: vi.fn().mockResolvedValue([
{ id: 'rt1', title: 'rTorrent Download 1', client: 'rtorrent' }
]),
testConnection: vi.fn().mockResolvedValue(true),
getClientStatus: vi.fn().mockResolvedValue({ status: 'active' })
}));
});
describe('DownloadClientRegistry', () => {
let testRegistry;
beforeEach(() => {
testRegistry = new DownloadClientRegistry();
vi.clearAllMocks();
});
describe('Initialization', () => {
it('should initialize all configured client types', async () => {
// Manually add mock clients to the registry
const mockSabClient = {
getClientType: () => 'sabnzbd',
getInstanceId: () => 'sab1',
name: 'SAB 1'
};
const mockQbClient = {
getClientType: () => 'qbittorrent',
getInstanceId: () => 'qb1',
name: 'QB 1'
};
const mockTransClient = {
getClientType: () => 'transmission',
getInstanceId: () => 'trans1',
name: 'Trans 1'
};
testRegistry.clients.set('sab1', mockSabClient);
testRegistry.clients.set('qb1', mockQbClient);
testRegistry.clients.set('trans1', mockTransClient);
expect(testRegistry.getAllClients()).toHaveLength(3);
expect(testRegistry.getClient('sab1')).toBeTruthy();
expect(testRegistry.getClient('qb1')).toBeTruthy();
expect(testRegistry.getClient('trans1')).toBeTruthy();
});
it('should handle empty config', async () => {
// Registry is already empty from beforeEach
expect(testRegistry.getAllClients()).toHaveLength(0);
});
it('should not initialize twice', async () => {
// Manually set initialized flag to true
testRegistry.initialized = true;
// Try to initialize again
await testRegistry.initialize();
// Config should not be called since initialized is true
expect(mockConfig.getSABnzbdInstances).not.toHaveBeenCalled();
});
it('should handle client creation errors gracefully', async () => {
// Registry is already empty from beforeEach
expect(testRegistry.getAllClients()).toHaveLength(0);
});
});
describe('Client Management', () => {
beforeEach(async () => {
// Manually add mock client to the registry
const mockSabClient = {
getClientType: () => 'sabnzbd',
getInstanceId: () => 'sab1',
name: 'SAB 1',
testConnection: vi.fn().mockResolvedValue(true),
getActiveDownloads: vi.fn().mockResolvedValue([])
};
testRegistry.clients.set('sab1', mockSabClient);
});
it('should get all clients', () => {
const clients = testRegistry.getAllClients();
expect(clients).toHaveLength(1);
expect(clients[0].getClientType()).toBe('sabnzbd');
});
it('should get client by ID', () => {
const client = testRegistry.getClient('sab1');
expect(client).toBeTruthy();
expect(client.getInstanceId()).toBe('sab1');
});
it('should return null for non-existent client', () => {
const client = testRegistry.getClient('nonexistent');
expect(client).toBeNull();
});
it('should get clients by type', () => {
const sabClients = testRegistry.getClientsByType('sabnzbd');
expect(sabClients).toHaveLength(1);
const qbClients = testRegistry.getClientsByType('qbittorrent');
expect(qbClients).toHaveLength(0);
});
});
describe('Download Management', () => {
beforeEach(async () => {
// Manually add mock clients to the registry
const mockSabClient = {
getClientType: () => 'sabnzbd',
getInstanceId: () => 'sab1',
name: 'SAB 1',
testConnection: vi.fn().mockResolvedValue(true),
getActiveDownloads: vi.fn().mockResolvedValue([
{ id: 'sab1', title: 'SAB Download 1', client: 'sabnzbd' }
])
};
const mockQbClient = {
getClientType: () => 'qbittorrent',
getInstanceId: () => 'qb1',
name: 'QB 1',
testConnection: vi.fn().mockResolvedValue(true),
getActiveDownloads: vi.fn().mockResolvedValue([
{ id: 'qb1', title: 'QB Download 1', client: 'qbittorrent' }
]),
resetFallbackFlag: vi.fn()
};
testRegistry.clients.set('sab1', mockSabClient);
testRegistry.clients.set('qb1', mockQbClient);
});
it('should get all downloads from all clients', async () => {
const downloads = await testRegistry.getAllDownloads();
expect(downloads).toHaveLength(2);
expect(downloads[0].client).toBe('sabnzbd');
expect(downloads[1].client).toBe('qbittorrent');
});
it('should reset fallback flags for qBittorrent clients', async () => {
const qbClient = testRegistry.getClient('qb1');
await testRegistry.getAllDownloads();
expect(qbClient.resetFallbackFlag).toHaveBeenCalled();
});
it('should get downloads grouped by client type', async () => {
const downloadsByType = await testRegistry.getDownloadsByClientType();
expect(downloadsByType.sabnzbd).toHaveLength(1);
expect(downloadsByType.qbittorrent).toHaveLength(1);
expect(downloadsByType.transmission).toBeUndefined();
});
it('should handle client errors gracefully', async () => {
const sabClient = testRegistry.getClient('sab1');
sabClient.getActiveDownloads.mockRejectedValue(new Error('Client error'));
const downloads = await testRegistry.getAllDownloads();
expect(downloads).toHaveLength(1); // Only qBittorrent succeeds
});
});
describe('Connection Testing', () => {
beforeEach(async () => {
// Manually add mock clients to the registry
const mockSabClient = {
getClientType: () => 'sabnzbd',
getInstanceId: () => 'sab1',
name: 'SAB 1',
testConnection: vi.fn().mockResolvedValue(true),
getActiveDownloads: vi.fn().mockResolvedValue([])
};
const mockQbClient = {
getClientType: () => 'qbittorrent',
getInstanceId: () => 'qb1',
name: 'QB 1',
testConnection: vi.fn().mockResolvedValue(true),
getActiveDownloads: vi.fn().mockResolvedValue([])
};
testRegistry.clients.set('sab1', mockSabClient);
testRegistry.clients.set('qb1', mockQbClient);
});
it('should test all connections', async () => {
const results = await testRegistry.testAllConnections();
expect(results).toHaveLength(2);
expect(results[0]).toEqual({
instanceId: 'sab1',
instanceName: 'SAB 1',
clientType: 'sabnzbd',
success: true,
error: null
});
expect(results[1]).toEqual({
instanceId: 'qb1',
instanceName: 'QB 1',
clientType: 'qbittorrent',
success: true,
error: null
});
});
it('should handle connection test failures', async () => {
const sabClient = testRegistry.getClient('sab1');
sabClient.testConnection.mockRejectedValue(new Error('Connection failed'));
const results = await testRegistry.testAllConnections();
expect(results[0].success).toBe(false);
expect(results[0].error).toBe('Connection failed');
});
});
describe('Client Status', () => {
beforeEach(async () => {
// Manually add a mock client to the registry
const mockClient = {
getClientType: () => 'sabnzbd',
getInstanceId: () => 'sab1',
name: 'SAB 1',
getClientStatus: vi.fn().mockResolvedValue({ status: 'active' })
};
testRegistry.clients.set('sab1', mockClient);
});
it('should get all client statuses', async () => {
const statuses = await testRegistry.getAllClientStatuses();
expect(statuses).toHaveLength(1);
expect(statuses[0]).toEqual({
instanceId: 'sab1',
instanceName: 'SAB 1',
clientType: 'sabnzbd',
status: { status: 'active' }
});
});
it('should handle status request errors', async () => {
const sabClient = testRegistry.getClient('sab1');
sabClient.getClientStatus.mockRejectedValue(new Error('Status error'));
const statuses = await testRegistry.getAllClientStatuses();
expect(statuses[0].status).toBeNull();
expect(statuses[0].error).toBe('Status error');
});
});
});
describe('Convenience Functions', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should delegate to singleton registry', async () => {
mockConfig.getSABnzbdInstances.mockReturnValue([]);
mockConfig.getQbittorrentInstances.mockReturnValue([]);
mockConfig.getTransmissionInstances.mockReturnValue([]);
await initializeClients();
expect(getAllClients()).toBeInstanceOf(Array);
expect(getClient('test')).toBeNull();
expect(getClientsByType('sabnzbd')).toBeInstanceOf(Array);
expect(await getAllDownloads()).toBeInstanceOf(Array);
expect(await getDownloadsByClientType()).toBeInstanceOf(Object);
expect(await testAllConnections()).toBeInstanceOf(Array);
expect(await getAllClientStatuses()).toBeInstanceOf(Array);
});
});
-178
View File
@@ -1,178 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Unit tests for server/utils/historyFetcher.js
*
* Covers:
* - classifySonarrEvent / classifyRadarrEvent event classification
* - fetchSonarrHistory / fetchRadarrHistory: successful fetch, cache hit, per-instance errors
* - invalidateHistoryCache
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import nock from 'nock';
// Env must be set before importing modules that read it at load time
process.env.SONARR_INSTANCES = JSON.stringify([
{ id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test', apiKey: 'sonarr-key' }
]);
process.env.RADARR_INSTANCES = JSON.stringify([
{ id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test', apiKey: 'radarr-key' }
]);
const { classifySonarrEvent, classifyRadarrEvent, fetchSonarrHistory, fetchRadarrHistory, invalidateHistoryCache } =
await import('../../server/utils/historyFetcher.js');
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
afterEach(() => {
nock.cleanAll();
invalidateHistoryCache();
});
describe('classifySonarrEvent', () => {
it('returns imported for downloadFolderImported', () => {
expect(classifySonarrEvent('downloadFolderImported')).toBe('imported');
});
it('returns imported for downloadImported', () => {
expect(classifySonarrEvent('downloadImported')).toBe('imported');
});
it('returns failed for downloadFailed', () => {
expect(classifySonarrEvent('downloadFailed')).toBe('failed');
});
it('returns failed for importFailed', () => {
expect(classifySonarrEvent('importFailed')).toBe('failed');
});
it('returns other for grabbed', () => {
expect(classifySonarrEvent('grabbed')).toBe('other');
});
it('returns other for unknown event', () => {
expect(classifySonarrEvent('someFutureEvent')).toBe('other');
});
});
describe('classifyRadarrEvent', () => {
it('returns imported for downloadFolderImported', () => {
expect(classifyRadarrEvent('downloadFolderImported')).toBe('imported');
});
it('returns failed for downloadFailed', () => {
expect(classifyRadarrEvent('downloadFailed')).toBe('failed');
});
it('returns other for grabbed', () => {
expect(classifyRadarrEvent('grabbed')).toBe('other');
});
});
describe('fetchSonarrHistory', () => {
const mockRecords = [
{
id: 1,
eventType: 'downloadFolderImported',
sourceTitle: 'Show.S01E01',
date: new Date().toISOString(),
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [] },
seriesId: 10
}
];
it('fetches records and tags them with _instanceUrl and _instanceName', async () => {
nock('https://sonarr.test')
.get('/api/v3/history')
.query(true)
.reply(200, { records: mockRecords });
const result = await fetchSonarrHistory(since);
expect(result).toHaveLength(1);
expect(result[0].series._instanceUrl).toBe('https://sonarr.test');
expect(result[0].series._instanceName).toBe('Main Sonarr');
expect(result[0]._instanceName).toBe('Main Sonarr');
});
it('returns cached data on second call without making a new HTTP request', async () => {
nock('https://sonarr.test')
.get('/api/v3/history')
.query(true)
.reply(200, { records: mockRecords });
const first = await fetchSonarrHistory(since);
// Second call — nock would throw if a second request was made
const second = await fetchSonarrHistory(since);
expect(second).toEqual(first);
});
it('returns empty array and does not throw when instance errors', async () => {
nock('https://sonarr.test')
.get('/api/v3/history')
.query(true)
.replyWithError('ECONNREFUSED');
const result = await fetchSonarrHistory(since);
expect(result).toEqual([]);
});
it('handles missing records key gracefully', async () => {
nock('https://sonarr.test')
.get('/api/v3/history')
.query(true)
.reply(200, {});
const result = await fetchSonarrHistory(since);
expect(result).toEqual([]);
});
});
describe('fetchRadarrHistory', () => {
const mockRecords = [
{
id: 2,
eventType: 'downloadFolderImported',
sourceTitle: 'My.Movie.2024',
date: new Date().toISOString(),
movie: { id: 20, title: 'My Movie', titleSlug: 'my-movie-2024', tags: [] },
movieId: 20
}
];
it('fetches records and tags them with _instanceUrl and _instanceName', async () => {
nock('https://radarr.test')
.get('/api/v3/history')
.query(true)
.reply(200, { records: mockRecords });
const result = await fetchRadarrHistory(since);
expect(result).toHaveLength(1);
expect(result[0].movie._instanceUrl).toBe('https://radarr.test');
expect(result[0].movie._instanceName).toBe('Main Radarr');
});
it('returns empty array on network error', async () => {
nock('https://radarr.test')
.get('/api/v3/history')
.query(true)
.replyWithError('timeout');
const result = await fetchRadarrHistory(since);
expect(result).toEqual([]);
});
});
describe('invalidateHistoryCache', () => {
it('forces a fresh fetch after invalidation', async () => {
nock('https://sonarr.test')
.get('/api/v3/history')
.query(true)
.reply(200, { records: [] });
await fetchSonarrHistory(since);
invalidateHistoryCache();
// Should make a second HTTP request — nock will satisfy it
nock('https://sonarr.test')
.get('/api/v3/history')
.query(true)
.reply(200, { records: [] });
const result = await fetchSonarrHistory(since);
expect(result).toEqual([]);
expect(nock.isDone()).toBe(true);
});
});
-370
View File
@@ -1,370 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Tests for server/utils/qbittorrent.js pure utility functions.
*
* mapTorrentToDownload, formatBytes, formatSpeed, and formatEta are all
* pure functions with no I/O ideal unit test targets. These power the
* dashboard card rendering so correctness matters for UX.
*/
import { mapTorrentToDownload, formatBytes, formatSpeed, formatEta } from '../../server/utils/qbittorrent.js';
import QBittorrentClient from '../../server/clients/QBittorrentClient.js';
import nock from 'nock';
// Minimal torrent fixture that satisfies mapTorrentToDownload's expectations
function makeTorrent(overrides = {}) {
return {
name: 'My.Show.S01E01.1080p.mkv',
state: 'downloading',
size: 1073741824, // 1 GB
completed: 536870912, // 512 MB
progress: 0.5,
dlspeed: 1048576, // 1 MB/s
eta: 512, // seconds
num_seeds: 10,
num_leechs: 3,
availability: 1.0,
hash: 'aabbccdd',
category: 'sonarr',
tags: '',
content_path: '/downloads/My.Show.S01E01.1080p.mkv',
save_path: '/downloads/',
instanceName: 'i3omb',
...overrides
};
}
const QBT_URL = 'http://qbittorrent.test:8080';
function makeClient(overrides = {}) {
return new QBittorrentClient({
id: 'test-qbt',
name: 'TestQBT',
url: QBT_URL,
username: 'admin',
password: 'adminadmin',
...overrides
});
}
function mockLogin() {
return nock(QBT_URL)
.post('/api/v2/auth/login')
.reply(200, {}, { 'set-cookie': ['SID=abc123; path=/'] });
}
function mockSync(rid, response) {
return nock(QBT_URL)
.get(`/api/v2/sync/maindata?rid=${rid}`)
.reply(200, response);
}
afterEach(() => {
nock.cleanAll();
});
describe('formatBytes', () => {
it('formats 0 bytes', () => expect(formatBytes(0)).toBe('0 B'));
it('formats bytes', () => expect(formatBytes(512)).toBe('512 B'));
it('formats kilobytes', () => expect(formatBytes(1024)).toBe('1 KB'));
it('formats megabytes', () => expect(formatBytes(1048576)).toBe('1 MB'));
it('formats gigabytes', () => expect(formatBytes(1073741824)).toBe('1 GB'));
it('formats fractional GB', () => expect(formatBytes(1610612736)).toBe('1.5 GB'));
});
describe('formatSpeed', () => {
it('appends /s to byte count', () => expect(formatSpeed(1048576)).toBe('1 MB/s'));
it('handles zero speed', () => expect(formatSpeed(0)).toBe('0 B/s'));
});
describe('formatEta', () => {
it('returns ∞ for qBittorrent unknown sentinel (8640000)', () => {
expect(formatEta(8640000)).toBe('∞');
});
it('returns ∞ for negative eta', () => expect(formatEta(-1)).toBe('∞'));
it('formats minutes only', () => expect(formatEta(90)).toBe('1m'));
it('formats hours and minutes', () => expect(formatEta(3661)).toBe('1h 1m'));
it('formats days, hours and minutes', () => expect(formatEta(90061)).toBe('1d 1h 1m'));
it('returns 0m for zero seconds', () => expect(formatEta(0)).toBe('0m'));
});
describe('mapTorrentToDownload', () => {
it('maps a downloading torrent correctly', () => {
const result = mapTorrentToDownload(makeTorrent());
expect(result.status).toBe('Downloading');
expect(result.progress).toBe('50.0');
expect(result.size).toBe('1 GB');
expect(result.speed).toBe('1 MB/s');
expect(result.eta).toBe('8m');
expect(result.seeds).toBe(10);
expect(result.peers).toBe(3);
expect(result.qbittorrent).toBe(true);
expect(result.instanceName).toBe('i3omb');
});
it('maps state: stalledDL → Downloading', () => {
expect(mapTorrentToDownload(makeTorrent({ state: 'stalledDL' })).status).toBe('Downloading');
});
it('maps state: uploading → Seeding', () => {
expect(mapTorrentToDownload(makeTorrent({ state: 'uploading' })).status).toBe('Seeding');
});
it('maps state: pausedDL → Paused', () => {
expect(mapTorrentToDownload(makeTorrent({ state: 'pausedDL' })).status).toBe('Paused');
});
it('maps state: stoppedUP → Stopped', () => {
expect(mapTorrentToDownload(makeTorrent({ state: 'stoppedUP' })).status).toBe('Stopped');
});
it('maps state: error → Error', () => {
expect(mapTorrentToDownload(makeTorrent({ state: 'error' })).status).toBe('Error');
});
it('passes through unknown state verbatim', () => {
expect(mapTorrentToDownload(makeTorrent({ state: 'weirdState' })).status).toBe('weirdState');
});
it('computes 100% progress for completed torrent', () => {
const result = mapTorrentToDownload(makeTorrent({ progress: 1.0 }));
expect(result.progress).toBe('100.0');
});
it('uses content_path as savePath when present', () => {
const result = mapTorrentToDownload(makeTorrent({ content_path: '/dl/file.mkv' }));
expect(result.savePath).toBe('/dl/file.mkv');
});
it('falls back to save_path when content_path is absent', () => {
const result = mapTorrentToDownload(makeTorrent({ content_path: null, save_path: '/dl/' }));
expect(result.savePath).toBe('/dl/');
});
});
describe('QBittorrentClient sync API', () => {
it('first call uses rid=0 and returns full torrent list', async () => {
mockLogin();
const client = makeClient();
await client.login();
mockSync(0, {
rid: 1,
full_update: true,
torrents: {
hash01: { name: 'Test1', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 }
}
});
const torrents = await client.getActiveDownloads();
expect(torrents).toHaveLength(1);
expect(torrents[0].title).toBe('Test1');
expect(torrents[0].instanceId).toBe('test-qbt');
expect(torrents[0].id).toBe('hash01');
expect(client.lastRid).toBe(1);
});
it('subsequent call uses last rid and merges delta', async () => {
mockLogin();
const client = makeClient();
await client.login();
// First call
mockSync(0, {
rid: 1,
full_update: true,
torrents: {
hash01: { name: 'Test1', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 }
}
});
await client.getActiveDownloads();
// Second call — delta
mockSync(1, {
rid: 2,
full_update: false,
torrents: {
hash01: { dlspeed: 200 }
}
});
const torrents = await client.getActiveDownloads();
expect(torrents).toHaveLength(1);
expect(torrents[0].speed).toBe(200);
expect(torrents[0].title).toBe('Test1');
expect(client.lastRid).toBe(2);
});
it('handles full_update=true on subsequent call', async () => {
mockLogin();
const client = makeClient();
await client.login();
// First call
mockSync(0, {
rid: 1,
full_update: true,
torrents: {
hash01: { name: 'Test1', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 }
}
});
await client.getActiveDownloads();
// Server forces full refresh
mockSync(1, {
rid: 2,
full_update: true,
torrents: {
hash02: { name: 'Test2', state: 'uploading', size: 2000, progress: 1.0, dlspeed: 0, eta: 0, num_seeds: 10, num_leechs: 0, availability: 1.0 }
}
});
const torrents = await client.getActiveDownloads();
expect(torrents).toHaveLength(1);
expect(torrents[0].title).toBe('Test2');
expect(torrents[0].id).toBe('hash02');
expect(client.lastRid).toBe(2);
});
it('removes torrents when torrents_removed is present', async () => {
mockLogin();
const client = makeClient();
await client.login();
mockSync(0, {
rid: 1,
full_update: true,
torrents: {
hash01: { name: 'Test1', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 }
}
});
await client.getActiveDownloads();
mockSync(1, {
rid: 2,
full_update: false,
torrents_removed: ['hash01']
});
const torrents = await client.getActiveDownloads();
expect(torrents).toHaveLength(0);
});
it('falls back to torrents/info when sync fails', async () => {
mockLogin();
const client = makeClient();
await client.login();
// Sync fails with 500
nock(QBT_URL)
.get('/api/v2/sync/maindata?rid=0')
.reply(500, { error: 'Internal Server Error' });
// Legacy succeeds
nock(QBT_URL)
.get('/api/v2/torrents/info')
.reply(200, [
{ name: 'Fallback', hash: 'fb01', state: 'downloading', size: 1073741824, progress: 0.5, dlspeed: 1048576, eta: 512, num_seeds: 10, num_leechs: 3, availability: 1.0 }
]);
const torrents = await client.getActiveDownloads();
expect(torrents).toHaveLength(1);
expect(torrents[0].title).toBe('Fallback');
expect(client.fallbackThisCycle).toBe(true);
});
it('uses legacy immediately if already fell back this cycle', async () => {
mockLogin();
const client = makeClient();
await client.login();
client.fallbackThisCycle = true;
// Only legacy should be called
nock(QBT_URL)
.get('/api/v2/torrents/info')
.reply(200, [
{ name: 'DirectLegacy', hash: 'dl01', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 }
]);
// Ensure sync is NOT called
const syncScope = nock(QBT_URL)
.get('/api/v2/sync/maindata?rid=0')
.reply(200, { rid: 1, full_update: true });
const torrents = await client.getActiveDownloads();
expect(torrents).toHaveLength(1);
expect(torrents[0].title).toBe('DirectLegacy');
expect(syncScope.isDone()).toBe(false);
});
it('re-authenticates on 403 during sync and retries', async () => {
mockLogin();
const client = makeClient();
await client.login();
// First sync call returns 403
nock(QBT_URL)
.get('/api/v2/sync/maindata?rid=0')
.reply(403, {});
// Re-login
nock(QBT_URL)
.post('/api/v2/auth/login')
.reply(200, {}, { 'set-cookie': ['SID=newtoken; path=/'] });
// Retry succeeds
nock(QBT_URL)
.get('/api/v2/sync/maindata?rid=0')
.reply(200, {
rid: 1,
full_update: true,
torrents: {
hash01: { name: 'AfterReauth', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 }
}
});
const torrents = await client.getActiveDownloads();
expect(torrents).toHaveLength(1);
expect(torrents[0].title).toBe('AfterReauth');
});
it('computes completed from size and progress when missing', async () => {
mockLogin();
const client = makeClient();
await client.login();
mockSync(0, {
rid: 1,
full_update: true,
torrents: {
hash01: { name: 'NoCompleted', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 }
}
});
const torrents = await client.getActiveDownloads();
expect(torrents[0].downloaded).toBe(500);
});
it('resets fallback flag when getAllTorrents resets it', async () => {
mockLogin();
const client = makeClient();
await client.login();
client.fallbackThisCycle = true;
// After reset, sync should be attempted
mockSync(0, {
rid: 1,
full_update: true,
torrents: {
hash01: { name: 'ResetWorks', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 }
}
});
// Simulate the reset that getAllTorrents performs
client.fallbackThisCycle = false;
const torrents = await client.getActiveDownloads();
expect(torrents[0].title).toBe('ResetWorks');
expect(client.fallbackThisCycle).toBe(false);
});
});
-141
View File
@@ -1,141 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Tests for server/middleware/requireAuth.js
*
* requireAuth guards all authenticated API routes. Tests exercise the full
* range of valid/invalid cookie states to ensure there's no bypass path.
*/
import requireAuth from '../../server/middleware/requireAuth.js';
// Build mock req/res/next objects
function makeReq({ signedCookie, plainCookie, cookieSecret } = {}) {
// Set COOKIE_SECRET so signed path is taken when provided
if (cookieSecret !== undefined) {
process.env.COOKIE_SECRET = cookieSecret;
} else {
delete process.env.COOKIE_SECRET;
}
return {
signedCookies: { emby_user: signedCookie },
cookies: { emby_user: plainCookie }
};
}
function makeRes() {
const res = {
statusCode: null,
body: null,
status(code) { this.statusCode = code; return this; },
json(body) { this.body = body; return this; }
};
return res;
}
afterEach(() => {
delete process.env.COOKIE_SECRET;
});
describe('requireAuth middleware', () => {
describe('valid sessions', () => {
it('calls next() with a valid signed cookie', () => {
const payload = JSON.stringify({ id: 'u1', name: 'Alice', isAdmin: true });
const req = makeReq({ signedCookie: payload, cookieSecret: 'secret' });
const res = makeRes();
const next = vi.fn();
requireAuth(req, res, next);
expect(next).toHaveBeenCalledOnce();
expect(req.user).toMatchObject({ id: 'u1', name: 'Alice', isAdmin: true });
});
it('calls next() with a valid unsigned cookie (no COOKIE_SECRET)', () => {
const payload = JSON.stringify({ id: 'u2', name: 'Bob', isAdmin: false });
const req = makeReq({ plainCookie: payload });
const res = makeRes();
const next = vi.fn();
requireAuth(req, res, next);
expect(next).toHaveBeenCalledOnce();
expect(req.user.id).toBe('u2');
});
it('coerces non-boolean isAdmin to boolean', () => {
const payload = JSON.stringify({ id: 'u3', name: 'Charlie', isAdmin: 1 });
const req = makeReq({ plainCookie: payload });
const res = makeRes();
const next = vi.fn();
requireAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.user.isAdmin).toBe(true);
});
});
describe('missing or invalid cookies', () => {
it('returns 401 when no cookie is present', () => {
const req = makeReq({});
const res = makeRes();
const next = vi.fn();
requireAuth(req, res, next);
expect(res.statusCode).toBe(401);
expect(next).not.toHaveBeenCalled();
});
it('returns 401 when signed cookie value is false (tampered)', () => {
// cookie-parser sets signed cookie to false when signature is invalid
const req = makeReq({ signedCookie: false, cookieSecret: 'secret' });
const res = makeRes();
const next = vi.fn();
requireAuth(req, res, next);
expect(res.statusCode).toBe(401);
});
it('returns 401 for malformed JSON in cookie', () => {
const req = makeReq({ plainCookie: 'not-json' });
const res = makeRes();
const next = vi.fn();
requireAuth(req, res, next);
expect(res.statusCode).toBe(401);
expect(res.body.error).toBe('Invalid session');
});
it('returns 401 when id is missing', () => {
const payload = JSON.stringify({ name: 'Alice', isAdmin: false });
const req = makeReq({ plainCookie: payload });
requireAuth(req, makeRes(), vi.fn());
// no next called — handled in the assertion below
const res = makeRes();
const next = vi.fn();
requireAuth(req, res, next);
expect(res.statusCode).toBe(401);
expect(next).not.toHaveBeenCalled();
});
it('returns 401 when name is missing', () => {
const payload = JSON.stringify({ id: 'u1', isAdmin: false });
const req = makeReq({ plainCookie: payload });
const res = makeRes();
requireAuth(req, res, vi.fn());
expect(res.statusCode).toBe(401);
});
it('returns 401 when id is empty string', () => {
const payload = JSON.stringify({ id: '', name: 'Alice', isAdmin: false });
const req = makeReq({ plainCookie: payload });
const res = makeRes();
requireAuth(req, res, vi.fn());
expect(res.statusCode).toBe(401);
});
});
});
-122
View File
@@ -1,122 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Tests for server/utils/sanitizeError.js
*
* Critical security tests: verify that API keys, tokens, passwords and other
* secrets are NEVER leaked in error messages returned to clients or written
* to logs. Every pattern here represents a real credential type used in the
* sofarr stack (SABnzbd apikey, Emby tokens, qBittorrent basic-auth URLs).
*/
import sanitizeError from '../../server/utils/sanitizeError.js';
describe('sanitizeError', () => {
describe('query-param secrets', () => {
it('redacts ?apikey= values', () => {
const err = new Error('Request failed: https://sabnzbd.local/api?apikey=abc123secret&output=json');
expect(sanitizeError(err)).toContain('[REDACTED]');
expect(sanitizeError(err)).not.toContain('abc123secret');
});
it('redacts &apikey= mid-URL', () => {
const err = new Error('GET https://host/path?mode=queue&apikey=SUPERSECRET&output=json');
expect(sanitizeError(err)).not.toContain('SUPERSECRET');
expect(sanitizeError(err)).toContain('[REDACTED]');
});
it('redacts ?token= values', () => {
const err = new Error('https://api.example.com/data?token=tok_private99');
expect(sanitizeError(err)).not.toContain('tok_private99');
});
it('redacts ?password= values', () => {
const err = new Error('Auth failed: https://service.local?password=hunter2');
expect(sanitizeError(err)).not.toContain('hunter2');
});
it('redacts ?api_key= values', () => {
const err = new Error('https://sonarr.local/api/v3/series?api_key=e583d270f89846478e42');
expect(sanitizeError(err)).not.toContain('e583d270f89846478e42');
});
it('preserves non-secret query params', () => {
const result = sanitizeError(new Error('GET /api?mode=queue&output=json'));
expect(result).toContain('mode=queue');
expect(result).toContain('output=json');
});
});
describe('HTTP auth headers', () => {
it('redacts X-Api-Key header values', () => {
const err = new Error('Request: x-api-key: e583d270f89846478e42dd3cf90bfb00');
expect(sanitizeError(err)).not.toContain('e583d270f89846478e42dd3cf90bfb00');
expect(sanitizeError(err)).toContain('[REDACTED]');
});
it('redacts X-MediaBrowser-Token header values', () => {
const err = new Error('x-mediabrowser-token: b862f3a43f4c417285043a11aa28b1f7');
expect(sanitizeError(err)).not.toContain('b862f3a43f4c417285043a11aa28b1f7');
});
it('redacts Authorization header values', () => {
const err = new Error('authorization: MediaBrowser Token="abc123", DeviceId="xyz"');
expect(sanitizeError(err)).not.toContain('abc123');
});
});
describe('bearer tokens', () => {
it('redacts Bearer token values', () => {
const err = new Error('Error: bearer eyJhbGciOiJIUzI1NiJ9.payload.sig');
expect(sanitizeError(err)).not.toContain('eyJhbGciOiJIUzI1NiJ9');
expect(sanitizeError(err)).toContain('bearer [REDACTED]');
});
it('is case-insensitive for BEARER', () => {
const err = new Error('BEARER TOKEN_VALUE_HERE');
expect(sanitizeError(err)).not.toContain('TOKEN_VALUE_HERE');
});
});
describe('basic-auth URLs', () => {
it('redacts user:pass@ in URLs', () => {
const err = new Error('GET http://admin:b053288369XX!@qbittorrent.local/api');
expect(sanitizeError(err)).not.toContain('b053288369XX!');
expect(sanitizeError(err)).not.toContain('admin:');
expect(sanitizeError(err)).toContain('//[REDACTED]@');
});
it('handles https:// basic auth', () => {
const err = new Error('https://user:s3cr3t@service.local/path');
expect(sanitizeError(err)).not.toContain('s3cr3t');
});
});
describe('edge cases', () => {
it('handles non-Error input (plain string)', () => {
const result = sanitizeError('plain string error');
expect(typeof result).toBe('string');
});
it('handles null gracefully', () => {
expect(() => sanitizeError(null)).not.toThrow();
});
it('handles undefined gracefully', () => {
expect(() => sanitizeError(undefined)).not.toThrow();
});
it('preserves non-sensitive error messages unchanged', () => {
const err = new Error('Connection refused: ECONNREFUSED 127.0.0.1:8080');
const result = sanitizeError(err);
expect(result).toContain('ECONNREFUSED');
expect(result).toContain('127.0.0.1:8080');
});
it('does not leak stack traces (returns message only)', () => {
const err = new Error('something went wrong');
const result = sanitizeError(err);
expect(result).not.toContain('at ');
expect(result).not.toContain('.js:');
});
});
});
-85
View File
@@ -1,85 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Tests for server/utils/tokenStore.js
*
* The token store persists Emby access tokens to disk (JSON file) so users
* survive server restarts without re-logging in. Tests verify the store/get/
* clear lifecycle, TTL expiry, and atomic write behaviour.
*
* Each test imports a FRESH module instance (vi.resetModules) so the
* module-level singleton state (loaded from disk) doesn't bleed between tests.
*/
import { vi } from 'vitest';
import fs from 'fs';
import path from 'path';
import os from 'os';
// Each test gets its own isolated temp dir
let tmpDir;
let tokenStore;
async function freshStore(dir) {
vi.resetModules();
process.env.DATA_DIR = dir;
const mod = await import('../../server/utils/tokenStore.js');
return mod;
}
beforeEach(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sofarr-ts-'));
tokenStore = await freshStore(tmpDir);
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe('tokenStore', () => {
it('stores and retrieves a token', () => {
tokenStore.storeToken('user1', 'access-token-abc');
const result = tokenStore.getToken('user1');
expect(result).not.toBeNull();
expect(result.accessToken).toBe('access-token-abc');
});
it('returns null for an unknown user', () => {
expect(tokenStore.getToken('nobody')).toBeNull();
});
it('clears a stored token', () => {
tokenStore.storeToken('user1', 'token-xyz');
tokenStore.clearToken('user1');
expect(tokenStore.getToken('user1')).toBeNull();
});
it('clearToken is a no-op for unknown user', () => {
expect(() => tokenStore.clearToken('ghost')).not.toThrow();
});
it('overwrites existing token on re-store', () => {
tokenStore.storeToken('user1', 'old-token');
tokenStore.storeToken('user1', 'new-token');
expect(tokenStore.getToken('user1').accessToken).toBe('new-token');
});
it('persists to disk (tokens.json exists after store)', () => {
tokenStore.storeToken('u1', 'tok');
const storePath = path.join(tmpDir, 'tokens.json');
expect(fs.existsSync(storePath)).toBe(true);
const data = JSON.parse(fs.readFileSync(storePath, 'utf8'));
expect(data.u1.accessToken).toBe('tok');
});
it('expires tokens older than 31 days on read', () => {
// Write an already-expired entry directly to disk
const expired = Date.now() - (32 * 24 * 60 * 60 * 1000);
const storePath = path.join(tmpDir, 'tokens.json');
fs.writeFileSync(storePath, JSON.stringify({ u1: { accessToken: 'old', createdAt: expired } }));
// Re-import to load from disk
vi.resetModules();
return import('../../server/utils/tokenStore.js').then(mod => {
expect(mod.getToken('u1')).toBeNull();
});
});
});
-85
View File
@@ -1,85 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Tests for server/middleware/verifyCsrf.js
*
* CSRF protection via the double-submit cookie pattern. These tests verify
* that the timing-safe comparison works correctly and that safe HTTP methods
* are correctly exempted.
*/
import verifyCsrf from '../../server/middleware/verifyCsrf.js';
function makeReq(method, cookieToken, headerToken) {
return {
method,
cookies: { csrf_token: cookieToken },
headers: { 'x-csrf-token': headerToken }
};
}
function makeRes() {
const res = {
statusCode: null,
body: null,
status(code) { this.statusCode = code; return this; },
json(body) { this.body = body; return this; }
};
return res;
}
describe('verifyCsrf middleware', () => {
describe('safe methods are exempted', () => {
for (const method of ['GET', 'HEAD', 'OPTIONS']) {
it(`allows ${method} with no CSRF token`, () => {
const next = vi.fn();
verifyCsrf(makeReq(method, undefined, undefined), makeRes(), next);
expect(next).toHaveBeenCalledOnce();
});
}
});
describe('mutating methods require valid token', () => {
const TOKEN = 'a'.repeat(64); // 64 hex chars = 32 bytes
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
it(`allows ${method} with matching tokens`, () => {
const next = vi.fn();
const res = makeRes();
verifyCsrf(makeReq(method, TOKEN, TOKEN), res, next);
expect(next).toHaveBeenCalledOnce();
expect(res.statusCode).toBeNull();
});
it(`blocks ${method} with mismatched tokens`, () => {
const next = vi.fn();
const res = makeRes();
verifyCsrf(makeReq(method, TOKEN, TOKEN.replace('a', 'b')), res, next);
expect(res.statusCode).toBe(403);
expect(next).not.toHaveBeenCalled();
});
it(`blocks ${method} with missing cookie token`, () => {
const next = vi.fn();
const res = makeRes();
verifyCsrf(makeReq(method, undefined, TOKEN), res, next);
expect(res.statusCode).toBe(403);
expect(res.body.error).toBe('CSRF token missing');
});
it(`blocks ${method} with missing header token`, () => {
const next = vi.fn();
const res = makeRes();
verifyCsrf(makeReq(method, TOKEN, undefined), res, next);
expect(res.statusCode).toBe(403);
});
}
it('blocks when tokens have different lengths (timing-safe path)', () => {
const next = vi.fn();
const res = makeRes();
verifyCsrf(makeReq('POST', 'short', 'much-longer-token-here'), res, next);
expect(res.statusCode).toBe(403);
expect(res.body.error).toBe('CSRF token invalid');
});
});
});
-42
View File
@@ -1,42 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
// Node environment for all tests (server-side CJS modules, no browser APIs needed)
environment: 'node',
// Global test helpers (describe, it, expect, vi) without per-file imports
globals: true,
// Run each test file in an isolated module registry so module-level state
// (tokenStore cache, config singletons) doesn't leak between files
isolate: true,
// Give each file its own data directory so tokenStore file I/O doesn't collide
setupFiles: ['./tests/setup.js'],
// Coverage via V8 (built into Node — no babel transform needed)
coverage: {
provider: 'v8',
reporter: ['text', 'lcov', 'html'],
reportsDirectory: './coverage',
// Only measure coverage on production source files
include: ['server/**/*.js'],
exclude: [
'server/index.js', // entry point with side-effects (process.exit, log streams)
'node_modules/**',
'tests/**',
'coverage/**'
],
// Global thresholds only — per-file thresholds are avoided because V8's
// coverage counting varies across Node versions (CI consistently reports
// ~10-15% lower than local for module-wrapper and require() lines).
// The overall numbers reflect that dashboard.js and poller.js are large
// untested files; the security-critical files (auth, middleware, utils)
// are well-covered by the 115 tests.
thresholds: {
lines: 22,
functions: 12,
branches: 8,
statements: 20
}
}
}
});